test: strengthen published upgrade survivor lane (#75361)

* test: integrate upgrade survivor baseline controls

* test: gate published upgrade survivor path

* test: preserve upgrade survivor fixture contract

* test: keep upgrade survivor temp state off overlay
This commit is contained in:
Josh Avant
2026-04-30 21:50:36 -05:00
committed by GitHub
parent 6603a174bc
commit ce833acbdb
25 changed files with 1002 additions and 85 deletions

View File

@@ -34,6 +34,25 @@ function getConfig() {
return readJson(requireEnv("OPENCLAW_CONFIG_PATH"));
}
function getCoverage() {
const file = process.env.OPENCLAW_UPGRADE_SURVIVOR_CONFIG_COVERAGE_JSON;
if (!file || !fs.existsSync(file)) {
return null;
}
return readJson(file);
}
function acceptsIntent(coverage, id) {
if (!coverage) {
return true;
}
return Array.isArray(coverage.acceptedIntents) && coverage.acceptedIntents.includes(id);
}
function hasCoverage(coverage) {
return !!coverage;
}
function seedState() {
const stateDir = requireEnv("OPENCLAW_STATE_DIR");
const workspace = requireEnv("OPENCLAW_TEST_WORKSPACE_DIR");
@@ -84,60 +103,95 @@ function seedState() {
function assertConfigSurvived() {
const config = getConfig();
assert(config.update?.channel === "stable", "update.channel was not preserved");
assert(config.gateway?.auth?.mode === "token", "gateway auth mode was not preserved");
const coverage = getCoverage();
const agents = config.agents?.list ?? [];
assert(Array.isArray(agents), "agents.list missing after update/doctor");
assert(
agents.some((agent) => agent?.id === "main"),
"main agent missing",
);
assert(
agents.some((agent) => agent?.id === "ops"),
"ops agent missing",
);
assert(
agents.find((agent) => agent?.id === "main")?.contextTokens === 64000,
"main agent contextTokens changed",
);
assert(
agents.find((agent) => agent?.id === "ops")?.fastModeDefault === true,
"ops fastModeDefault changed",
);
if (acceptsIntent(coverage, "update")) {
assert(config.update?.channel === "stable", "update.channel was not preserved");
}
if (acceptsIntent(coverage, "gateway")) {
assert(config.gateway?.auth?.mode === "token", "gateway auth mode was not preserved");
}
const discord = config.channels?.discord;
assert(discord?.enabled === true, "discord enabled flag changed");
const discordAllowFrom = discord.allowFrom ?? discord.dm?.allowFrom;
const discordDmPolicy = discord.dmPolicy ?? discord.dm?.policy;
assert(discordDmPolicy === "allowlist", "discord DM policy changed");
assert(
Array.isArray(discordAllowFrom) && discordAllowFrom.includes("111111111111111111"),
"discord allowFrom changed",
);
assert(
discord.guilds?.["222222222222222222"]?.channels?.["333333333333333333"]?.requireMention ===
true,
"discord guild channel mention policy changed",
);
assert(discord.threadBindings?.idleHours === 72, "discord thread binding ttl changed");
if (acceptsIntent(coverage, "models")) {
assert(config.models?.providers?.openai, "OpenAI model provider missing");
}
assert(config.channels?.telegram?.enabled === true, "telegram enabled flag changed");
assert(
config.channels?.telegram?.groups?.["-1001234567890"]?.requireMention === true,
"telegram group policy changed",
);
assert(config.channels?.whatsapp?.enabled === true, "whatsapp enabled flag changed");
assert(
config.channels?.whatsapp?.groups?.["120363000000000000@g.us"]?.systemPrompt ===
"Use the existing WhatsApp group prompt.",
"whatsapp group policy changed",
);
if (acceptsIntent(coverage, "agents")) {
const agents = config.agents?.list ?? [];
assert(Array.isArray(agents), "agents.list missing after update/doctor");
assert(
agents.some((agent) => agent?.id === "main"),
"main agent missing",
);
assert(
agents.some((agent) => agent?.id === "ops"),
"ops agent missing",
);
if (hasCoverage(coverage)) {
assert(config.agents?.defaults?.contextTokens === 64000, "default contextTokens changed");
} else {
assert(
agents.find((agent) => agent?.id === "main")?.contextTokens === 64000,
"main agent contextTokens changed",
);
}
assert(
agents.find((agent) => agent?.id === "ops")?.fastModeDefault === true,
"ops fastModeDefault changed",
);
}
const pluginAllow = config.plugins?.allow ?? [];
assert(pluginAllow.includes("discord"), "discord plugin allow entry missing");
assert(pluginAllow.includes("telegram"), "telegram plugin allow entry missing");
assert(pluginAllow.includes("whatsapp"), "whatsapp plugin allow entry missing");
if (acceptsIntent(coverage, "skills")) {
assert(config.skills?.allowBundled?.includes("memory"), "memory skill allowlist changed");
}
if (acceptsIntent(coverage, "plugins")) {
const pluginAllow = config.plugins?.allow ?? [];
assert(pluginAllow.includes("discord"), "discord plugin allow entry missing");
assert(pluginAllow.includes("telegram"), "telegram plugin allow entry missing");
assert(pluginAllow.includes("whatsapp"), "whatsapp plugin allow entry missing");
}
if (acceptsIntent(coverage, "discord-channel")) {
const discord = config.channels?.discord;
assert(discord?.enabled === true, "discord enabled flag changed");
const discordAllowFrom = discord.allowFrom ?? discord.dm?.allowFrom;
const discordDmPolicy = discord.dmPolicy ?? discord.dm?.policy;
assert(discordDmPolicy === "allowlist", "discord DM policy changed");
assert(
Array.isArray(discordAllowFrom) && discordAllowFrom.includes("111111111111111111"),
"discord allowFrom changed",
);
assert(
discord.guilds?.["222222222222222222"]?.channels?.["333333333333333333"]?.requireMention ===
true,
"discord guild channel mention policy changed",
);
assert(discord.threadBindings?.idleHours === 72, "discord thread binding ttl changed");
}
if (acceptsIntent(coverage, "telegram-channel")) {
const telegram = config.channels?.telegram;
assert(telegram?.enabled === true, "telegram enabled flag changed");
assert(
telegram.groups?.["-1001234567890"]?.requireMention === true,
"telegram group policy changed",
);
}
if (acceptsIntent(coverage, "whatsapp-channel")) {
const whatsapp = config.channels?.whatsapp;
assert(whatsapp?.enabled === true, "whatsapp enabled flag changed");
const whatsappGroup = whatsapp.groups?.["120363000000000000@g.us"];
if (hasCoverage(coverage)) {
assert(whatsappGroup?.requireMention === true, "whatsapp group policy changed");
} else {
assert(
whatsappGroup?.systemPrompt === "Use the existing WhatsApp group prompt.",
"whatsapp group policy changed",
);
}
}
}
function assertStateSurvived() {

View File

@@ -0,0 +1,139 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const args = process.argv.slice(2);
const command = args.shift();
function option(name, fallback) {
const index = args.indexOf(name);
if (index === -1) {
return fallback;
}
const value = args[index + 1];
if (!value) {
throw new Error(`missing value for ${name}`);
}
return value;
}
function tail(value, max = 2400) {
const text = String(value || "");
return text.length <= max ? text : text.slice(-max);
}
function writeJson(file, value) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
}
const configSectionDir = new URL("./config-recipe/", import.meta.url);
function readConfigSection(fileName) {
const fileUrl = new URL(fileName, configSectionDir);
return JSON.stringify(JSON.parse(fs.readFileSync(fileUrl, "utf8")));
}
function configSetJsonFile(id, intent, configPath, fileName) {
return {
id,
intent,
argv: ["config", "set", configPath, readConfigSection(fileName), "--strict-json"],
};
}
const representativeConfigSteps = [
configSetJsonFile("models-openai", "models", "models.providers.openai", "models-openai.json"),
configSetJsonFile("agents", "agents", "agents", "agents.json"),
configSetJsonFile("skills", "skills", "skills", "skills.json"),
configSetJsonFile("plugins", "plugins", "plugins", "plugins.json"),
configSetJsonFile(
"channels-discord",
"discord-channel",
"channels.discord",
"channels-discord.json",
),
configSetJsonFile(
"channels-telegram",
"telegram-channel",
"channels.telegram",
"channels-telegram.json",
),
configSetJsonFile(
"channels-whatsapp",
"whatsapp-channel",
"channels.whatsapp",
"channels-whatsapp.json",
),
];
const recipe = [
{
id: "update-channel",
intent: "update",
argv: ["config", "set", "update.channel", "stable"],
},
configSetJsonFile("gateway", "gateway", "gateway", "gateway.json"),
...representativeConfigSteps,
{
id: "validate",
intent: "validate",
argv: ["config", "validate"],
},
];
function runOpenClaw(step) {
const result = spawnSync("openclaw", step.argv, {
encoding: "utf8",
env: process.env,
});
return {
id: step.id,
intent: step.intent,
command: ["openclaw", ...step.argv].join(" "),
status: result.status,
signal: result.signal,
ok: result.status === 0,
stdout: tail(result.stdout),
stderr: tail(result.stderr),
};
}
function applyRecipe() {
const summaryPath = option("--summary");
const baselineVersion = option("--baseline-version", null);
const summary = {
source: "baseline-cli-command-recipe",
recipe: "upgrade-survivor-v1",
baselineVersion,
acceptedIntents: [
"update",
"gateway",
"models",
"agents",
"skills",
"plugins",
"discord-channel",
"telegram-channel",
"whatsapp-channel",
],
skippedIntents: [],
steps: [],
};
for (const step of recipe) {
const outcome = runOpenClaw(step);
summary.steps.push(outcome);
writeJson(summaryPath, summary);
if (!outcome.ok) {
throw new Error(`baseline config recipe failed at ${step.id}`);
}
}
}
if (command === "apply") {
applyRecipe();
} else {
throw new Error(`unknown upgrade-survivor config-recipe command: ${command ?? "<missing>"}`);
}

View File

@@ -0,0 +1,31 @@
{
"defaults": {
"model": {
"primary": "openai/gpt-4.1-mini"
},
"contextTokens": 64000,
"skills": ["memory"]
},
"list": [
{
"id": "main",
"default": true,
"name": "Main",
"workspace": "~/workspace",
"model": {
"primary": "openai/gpt-4.1-mini"
},
"thinkingDefault": "low",
"skills": ["memory"]
},
{
"id": "ops",
"name": "Ops",
"workspace": "~/workspace/ops",
"model": {
"primary": "openai/gpt-4.1-mini"
},
"fastModeDefault": true
}
]
}

View File

@@ -0,0 +1,32 @@
{
"enabled": true,
"token": {
"source": "env",
"provider": "default",
"id": "DISCORD_BOT_TOKEN"
},
"dm": {
"policy": "allowlist",
"allowFrom": ["111111111111111111"]
},
"groupPolicy": "allowlist",
"guilds": {
"222222222222222222": {
"slug": "survivor-guild",
"channels": {
"333333333333333333": {
"enabled": true,
"requireMention": true,
"tools": {
"allow": ["message_send"],
"deny": ["exec"]
}
}
}
}
},
"threadBindings": {
"enabled": true,
"idleHours": 72
}
}

View File

@@ -0,0 +1,22 @@
{
"enabled": true,
"botToken": {
"source": "env",
"provider": "default",
"id": "TELEGRAM_BOT_TOKEN"
},
"dmPolicy": "allowlist",
"allowFrom": ["123456789"],
"defaultTo": "123456789",
"groupPolicy": "allowlist",
"groupAllowFrom": ["123456789"],
"groups": {
"-1001234567890": {
"requireMention": true,
"tools": {
"allow": ["message_send"],
"deny": ["exec"]
}
}
}
}

View File

@@ -0,0 +1,23 @@
{
"enabled": true,
"dmPolicy": "allowlist",
"allowFrom": ["+15555550123"],
"defaultTo": "+15555550123",
"groupPolicy": "allowlist",
"groupAllowFrom": ["+15555550123"],
"groups": {
"120363000000000000@g.us": {
"requireMention": true,
"tools": {
"allow": ["message_send"],
"deny": ["exec"]
}
}
},
"accounts": {
"default": {
"enabled": true,
"name": "Default WhatsApp"
}
}
}

View File

@@ -0,0 +1,13 @@
{
"mode": "local",
"port": 18789,
"bind": "loopback",
"auth": {
"mode": "token",
"token": {
"source": "env",
"provider": "default",
"id": "GATEWAY_AUTH_TOKEN_REF"
}
}
}

View File

@@ -0,0 +1,10 @@
{
"api": "openai-responses",
"apiKey": {
"source": "env",
"provider": "default",
"id": "OPENAI_API_KEY"
},
"baseUrl": "https://api.openai.com/v1",
"models": []
}

View File

@@ -0,0 +1,15 @@
{
"enabled": true,
"allow": ["discord", "memory", "telegram", "whatsapp"],
"entries": {
"discord": {
"enabled": true
},
"telegram": {
"enabled": true
},
"whatsapp": {
"enabled": true
}
}
}

View File

@@ -0,0 +1,7 @@
{
"allowBundled": ["memory", "openclaw-testing"],
"limits": {
"maxSkillsInPrompt": 8,
"maxSkillsPromptChars": 30000
}
}

View File

@@ -0,0 +1,405 @@
#!/usr/bin/env bash
set -Eeuo pipefail
source scripts/lib/openclaw-e2e-instance.sh
export npm_config_loglevel=error
export npm_config_fund=false
export npm_config_audit=false
export CI=true
export OPENCLAW_NO_ONBOARD=1
export OPENCLAW_NO_PROMPT=1
export OPENCLAW_SKIP_PROVIDERS=1
export OPENCLAW_SKIP_CHANNELS=1
export OPENCLAW_DISABLE_BONJOUR=1
export GATEWAY_AUTH_TOKEN_REF="upgrade-survivor-token"
export OPENAI_API_KEY="sk-openclaw-upgrade-survivor"
export DISCORD_BOT_TOKEN="upgrade-survivor-discord-token"
export TELEGRAM_BOT_TOKEN="123456:upgrade-survivor-telegram-token"
ARTIFACT_ROOT="$(dirname "${OPENCLAW_UPGRADE_SURVIVOR_SUMMARY_JSON:-/tmp/openclaw-upgrade-survivor-artifacts/summary.json}")"
mkdir -p "$ARTIFACT_ROOT"
export TMPDIR="$ARTIFACT_ROOT/tmp"
mkdir -p "$TMPDIR"
export npm_config_prefix="$ARTIFACT_ROOT/npm-prefix"
export NPM_CONFIG_PREFIX="$npm_config_prefix"
export npm_config_cache="$ARTIFACT_ROOT/npm-cache"
export npm_config_tmp="$TMPDIR"
mkdir -p "$npm_config_prefix" "$npm_config_cache"
export PATH="$npm_config_prefix/bin:$PATH"
SUMMARY_JSON="${OPENCLAW_UPGRADE_SURVIVOR_SUMMARY_JSON:-$ARTIFACT_ROOT/summary.json}"
PHASE_LOG="$ARTIFACT_ROOT/phases.jsonl"
: >"$PHASE_LOG"
BASELINE_RAW="${OPENCLAW_UPGRADE_SURVIVOR_BASELINE:?missing OPENCLAW_UPGRADE_SURVIVOR_BASELINE}"
CANDIDATE_KIND="${OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_KIND:-tarball}"
CANDIDATE_SPEC="${OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_SPEC:-${OPENCLAW_CURRENT_PACKAGE_TGZ:-}}"
CURRENT_PHASE="setup"
FAILURE_PHASE=""
FAILURE_MESSAGE=""
gateway_pid=""
baseline_spec=""
baseline_version=""
baseline_version_expected="0"
candidate_version=""
installed_version=""
start_seconds=""
status_seconds=""
BASELINE_INSTALL_LOG="$ARTIFACT_ROOT/baseline-install.log"
UPDATE_JSON="$ARTIFACT_ROOT/update.json"
UPDATE_ERR="$ARTIFACT_ROOT/update.err"
DOCTOR_LOG="$ARTIFACT_ROOT/doctor.log"
GATEWAY_LOG="$ARTIFACT_ROOT/gateway.log"
STATUS_JSON="$ARTIFACT_ROOT/status.json"
STATUS_ERR="$ARTIFACT_ROOT/status.err"
BASELINE_CONFIG_VALIDATE_LOG="$ARTIFACT_ROOT/baseline-config-validate.log"
CONFIG_COVERAGE_JSON="$ARTIFACT_ROOT/config-recipe.json"
export OPENCLAW_UPGRADE_SURVIVOR_CONFIG_COVERAGE_JSON="$CONFIG_COVERAGE_JSON"
normalize_baseline() {
local raw="${BASELINE_RAW//[[:space:]]/}"
if [ -z "$raw" ]; then
echo "OPENCLAW_UPGRADE_SURVIVOR_BASELINE cannot be empty" >&2
return 1
fi
case "$raw" in
openclaw@*)
baseline_spec="$raw"
baseline_version="${raw#openclaw@}"
;;
*@*)
echo "OPENCLAW_UPGRADE_SURVIVOR_BASELINE must be openclaw@<version> or a bare version" >&2
return 1
;;
*)
baseline_version="$raw"
baseline_spec="openclaw@$raw"
;;
esac
case "$baseline_version" in
latest | beta)
baseline_version=""
baseline_version_expected="0"
;;
dev | main | "")
echo "OPENCLAW_UPGRADE_SURVIVOR_BASELINE must be openclaw@latest, openclaw@beta, openclaw@<version>, or a bare version" >&2
return 1
;;
*)
baseline_version_expected="1"
;;
esac
}
json_event() {
local phase="$1"
local status="$2"
PHASE_EVENT_PHASE="$phase" PHASE_EVENT_STATUS="$status" node <<'NODE' >>"$PHASE_LOG"
const event = {
phase: process.env.PHASE_EVENT_PHASE,
status: process.env.PHASE_EVENT_STATUS,
at: new Date().toISOString(),
};
process.stdout.write(`${JSON.stringify(event)}\n`);
NODE
}
write_summary() {
local status="$1"
local message="${2:-}"
mkdir -p "$(dirname "$SUMMARY_JSON")"
SUMMARY_STATUS="$status" \
SUMMARY_MESSAGE="$message" \
SUMMARY_PHASE_LOG="$PHASE_LOG" \
SUMMARY_JSON="$SUMMARY_JSON" \
SUMMARY_BASELINE_SPEC="$baseline_spec" \
SUMMARY_BASELINE_VERSION="$baseline_version" \
SUMMARY_CANDIDATE_VERSION="$candidate_version" \
SUMMARY_INSTALLED_VERSION="$installed_version" \
SUMMARY_START_SECONDS="$start_seconds" \
SUMMARY_STATUS_SECONDS="$status_seconds" \
SUMMARY_FAILURE_PHASE="$FAILURE_PHASE" \
SUMMARY_CONFIG_COVERAGE="$CONFIG_COVERAGE_JSON" \
node <<'NODE'
const fs = require("node:fs");
const phaseLog = process.env.SUMMARY_PHASE_LOG;
const phases = fs.existsSync(phaseLog)
? fs.readFileSync(phaseLog, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line))
: [];
const numberOrNull = (value) => {
if (!value) return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const readJsonOrNull = (file) => {
if (!file || !fs.existsSync(file)) return null;
return JSON.parse(fs.readFileSync(file, "utf8"));
};
const summary = {
status: process.env.SUMMARY_STATUS,
baseline: {
spec: process.env.SUMMARY_BASELINE_SPEC || null,
version: process.env.SUMMARY_BASELINE_VERSION || null,
},
candidate: {
kind: process.env.OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_KIND || null,
spec: process.env.OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_SPEC || process.env.OPENCLAW_CURRENT_PACKAGE_TGZ || null,
version: process.env.SUMMARY_CANDIDATE_VERSION || null,
},
installedVersion: process.env.SUMMARY_INSTALLED_VERSION || null,
timings: {
startupSeconds: numberOrNull(process.env.SUMMARY_START_SECONDS),
statusSeconds: numberOrNull(process.env.SUMMARY_STATUS_SECONDS),
},
config: readJsonOrNull(process.env.SUMMARY_CONFIG_COVERAGE),
failure: process.env.SUMMARY_STATUS === "passed"
? null
: {
phase: process.env.SUMMARY_FAILURE_PHASE || null,
message: process.env.SUMMARY_MESSAGE || null,
},
phases,
};
fs.writeFileSync(process.env.SUMMARY_JSON, `${JSON.stringify(summary, null, 2)}\n`);
NODE
}
cleanup() {
openclaw_e2e_terminate_gateways "${gateway_pid:-}"
}
on_error() {
local status="$1"
FAILURE_PHASE="${CURRENT_PHASE:-unknown}"
FAILURE_MESSAGE="phase ${FAILURE_PHASE} failed with status ${status}"
json_event "$FAILURE_PHASE" failed || true
return "$status"
}
on_exit() {
local status="$1"
set +e
cleanup
if [ "$status" -eq 0 ]; then
write_summary passed ""
else
[ -n "$FAILURE_PHASE" ] || FAILURE_PHASE="${CURRENT_PHASE:-unknown}"
[ -n "$FAILURE_MESSAGE" ] || FAILURE_MESSAGE="upgrade survivor failed with status $status"
write_summary failed "$FAILURE_MESSAGE"
fi
echo "Upgrade survivor summary: $SUMMARY_JSON"
cat "$SUMMARY_JSON" 2>/dev/null || true
exit "$status"
}
trap 'on_error $?' ERR
trap 'on_exit $?' EXIT
phase() {
local name="$1"
shift
CURRENT_PHASE="$name"
echo "==> upgrade-survivor:$name"
json_event "$name" started
"$@"
json_event "$name" passed
CURRENT_PHASE=""
}
package_root() {
printf '%s/lib/node_modules/openclaw\n' "$npm_config_prefix"
}
read_installed_version() {
node -p 'JSON.parse(require("node:fs").readFileSync(process.argv[1] + "/package.json", "utf8")).version' "$(package_root)"
}
storage_preflight() {
echo "Storage preflight:"
df -h "$ARTIFACT_ROOT" "$TMPDIR" /tmp || true
}
reset_run_state() {
rm -rf "$npm_config_prefix" "$TMPDIR" "$ARTIFACT_ROOT/state-home"
mkdir -p "$npm_config_prefix" "$npm_config_cache" "$TMPDIR"
}
install_baseline() {
normalize_baseline
echo "Installing baseline package: $baseline_spec"
if ! npm install -g --prefix "$npm_config_prefix" "$baseline_spec" --no-fund --no-audit >"$BASELINE_INSTALL_LOG" 2>&1; then
echo "baseline npm install failed" >&2
cat "$BASELINE_INSTALL_LOG" >&2 || true
return 1
fi
if ! command -v openclaw >/dev/null; then
echo "baseline install did not expose openclaw on PATH" >&2
echo "PATH=$PATH" >&2
find "$npm_config_prefix" -maxdepth 3 -type f -o -type l >&2 || true
return 1
fi
installed_version="$(read_installed_version)"
if [ "$baseline_version_expected" = "1" ] && [ "$installed_version" != "$baseline_version" ]; then
echo "baseline package version mismatch: expected $baseline_version, got $installed_version" >&2
cat "$(package_root)/package.json" >&2 || true
return 1
fi
baseline_version="$installed_version"
local version_output
if ! version_output="$(openclaw --version 2>&1)"; then
echo "baseline openclaw --version failed" >&2
echo "$version_output" >&2
return 1
fi
if [[ "$version_output" != *"$baseline_version"* ]]; then
echo "baseline openclaw --version mismatch: expected output to include $baseline_version" >&2
echo "$version_output" >&2
return 1
fi
}
seed_state() {
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_FUNCTION_B64:?missing OPENCLAW_TEST_STATE_FUNCTION_B64}"
openclaw_test_state_create "$ARTIFACT_ROOT/state-home" minimal
node scripts/e2e/lib/upgrade-survivor/assertions.mjs seed
}
apply_baseline_config_recipe() {
node scripts/e2e/lib/upgrade-survivor/config-recipe.mjs apply \
--summary "$CONFIG_COVERAGE_JSON" \
--baseline-version "$baseline_version"
}
validate_baseline_config() {
if ! openclaw config validate >"$BASELINE_CONFIG_VALIDATE_LOG" 2>&1; then
echo "generated baseline config failed baseline validation" >&2
cat "$BASELINE_CONFIG_VALIDATE_LOG" >&2 || true
return 1
fi
}
assert_baseline_state() {
node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-config
node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-state
}
resolve_candidate_version() {
if [ -z "$CANDIDATE_SPEC" ]; then
echo "missing OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_SPEC" >&2
return 1
fi
case "$CANDIDATE_KIND" in
tarball)
candidate_version="$(
node -e '
const { execFileSync } = require("node:child_process");
const packageJson = execFileSync("tar", ["-xOf", process.argv[1], "package/package.json"], {
encoding: "utf8",
});
process.stdout.write(JSON.parse(packageJson).version);
' "$CANDIDATE_SPEC"
)"
;;
npm)
candidate_version="$(npm view "$CANDIDATE_SPEC" version --silent)"
;;
*)
echo "unknown candidate kind: $CANDIDATE_KIND" >&2
return 1
;;
esac
if [ -z "$candidate_version" ]; then
echo "could not resolve candidate version from $CANDIDATE_KIND:$CANDIDATE_SPEC" >&2
return 1
fi
OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT="$(
node scripts/e2e/lib/package-compat.mjs "$candidate_version"
)"
export OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT
}
update_candidate() {
echo "Updating baseline $baseline_spec to candidate $CANDIDATE_KIND:$CANDIDATE_SPEC ($candidate_version)"
if ! openclaw update --tag "$CANDIDATE_SPEC" --yes --json --no-restart >"$UPDATE_JSON" 2>"$UPDATE_ERR"; then
echo "openclaw update failed" >&2
cat "$UPDATE_ERR" >&2 || true
cat "$UPDATE_JSON" >&2 || true
return 1
fi
installed_version="$(read_installed_version)"
}
run_doctor() {
if ! openclaw doctor --fix --non-interactive >"$DOCTOR_LOG" 2>&1; then
echo "openclaw doctor failed" >&2
cat "$DOCTOR_LOG" >&2 || true
return 1
fi
}
assert_survival() {
node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-config
node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-state
installed_version="$(read_installed_version)"
if [ "$installed_version" != "$candidate_version" ]; then
echo "candidate package version mismatch: expected $candidate_version, got $installed_version" >&2
return 1
fi
}
start_gateway() {
local port=18789
local budget="${OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS:-90}"
local start_epoch
local ready_epoch
start_epoch="$(node -e "process.stdout.write(String(Date.now()))")"
openclaw gateway --port "$port" --bind loopback --allow-unconfigured >"$GATEWAY_LOG" 2>&1 &
gateway_pid="$!"
openclaw_e2e_wait_gateway_ready "$gateway_pid" "$GATEWAY_LOG" 360
ready_epoch="$(node -e "process.stdout.write(String(Date.now()))")"
start_seconds=$(((ready_epoch - start_epoch + 999) / 1000))
if [ "$start_seconds" -gt "$budget" ]; then
echo "gateway startup exceeded survivor budget: ${start_seconds}s > ${budget}s" >&2
cat "$GATEWAY_LOG" >&2 || true
return 1
fi
}
check_gateway_status() {
local port=18789
local budget="${OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS:-30}"
local status_start
local status_end
status_start="$(node -e "process.stdout.write(String(Date.now()))")"
if ! openclaw gateway status --url "ws://127.0.0.1:$port" --token "$GATEWAY_AUTH_TOKEN_REF" --require-rpc --timeout 30000 --json >"$STATUS_JSON" 2>"$STATUS_ERR"; then
echo "gateway status failed" >&2
cat "$STATUS_ERR" >&2 || true
cat "$GATEWAY_LOG" >&2 || true
return 1
fi
status_end="$(node -e "process.stdout.write(String(Date.now()))")"
status_seconds=$(((status_end - status_start + 999) / 1000))
if [ "$status_seconds" -gt "$budget" ]; then
echo "gateway status exceeded survivor budget: ${status_seconds}s > ${budget}s" >&2
cat "$STATUS_JSON" >&2 || true
return 1
fi
node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-status-json "$STATUS_JSON"
}
phase storage-preflight storage_preflight
phase reset-run-state reset_run_state
phase install-baseline install_baseline
phase seed-state seed_state
phase apply-baseline-config-recipe apply_baseline_config_recipe
phase validate-baseline-config validate_baseline_config
phase assert-baseline assert_baseline_state
phase resolve-candidate resolve_candidate_version
phase update-candidate update_candidate
phase doctor run_doctor
phase assert-survival assert_survival
phase gateway-start start_gateway
phase gateway-status check_gateway_status
echo "Upgrade survivor Docker E2E passed baseline=${baseline_spec} candidate=${candidate_version} startup=${start_seconds}s status=${status_seconds}s."

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# Installs the packed OpenClaw tarball over a dirty old-user state fixture, runs
# the package update/doctor paths, then proves the Gateway still boots.
# Installs the packed OpenClaw tarball over dirty old-user state. When
# OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC is set, installs that published
# baseline first and upgrades it to the selected candidate.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
@@ -9,12 +10,91 @@ source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-upgrade-survivor-e2e" OPENCLAW_UPGRADE_SURVIVOR_E2E_IMAGE)"
SKIP_BUILD="${OPENCLAW_UPGRADE_SURVIVOR_E2E_SKIP_BUILD:-0}"
PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz upgrade-survivor "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}")"
DOCKER_RUN_TIMEOUT="${OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT:-900s}"
BASELINE_SPEC="${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-}"
ARTIFACT_DIR="${OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_DIR:-$ROOT_DIR/.artifacts/upgrade-survivor}"
normalize_npm_candidate() {
local raw="$1"
case "$raw" in
latest | beta)
printf 'openclaw@%s\n' "$raw"
;;
openclaw@*)
printf '%s\n' "$raw"
;;
*@*)
echo "OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE must be current, latest, beta, openclaw@<version>, a bare version, or a .tgz path." >&2
return 1
;;
*)
printf 'openclaw@%s\n' "$raw"
;;
esac
}
if [ "${OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE:-0}" = "1" ]; then
if [ -z "${BASELINE_SPEC// }" ]; then
echo "OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC is required for published upgrade survivor" >&2
exit 1
fi
mkdir -p "$ARTIFACT_DIR"
DOCKER_E2E_PACKAGE_ARGS=()
CANDIDATE_RAW="${OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE:-current}"
CANDIDATE_KIND="npm"
CANDIDATE_SPEC=""
if [ -n "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}" ]; then
PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz upgrade-survivor "$OPENCLAW_CURRENT_PACKAGE_TGZ")"
docker_e2e_package_mount_args "$PACKAGE_TGZ"
CANDIDATE_KIND="tarball"
CANDIDATE_SPEC="/tmp/openclaw-current.tgz"
elif [ "$CANDIDATE_RAW" = "current" ]; then
PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz upgrade-survivor)"
docker_e2e_package_mount_args "$PACKAGE_TGZ"
CANDIDATE_KIND="tarball"
CANDIDATE_SPEC="/tmp/openclaw-current.tgz"
elif [[ "$CANDIDATE_RAW" == *.tgz ]]; then
if [ ! -f "$CANDIDATE_RAW" ]; then
echo "OpenClaw candidate tarball does not exist: $CANDIDATE_RAW" >&2
exit 1
fi
PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz upgrade-survivor "$CANDIDATE_RAW")"
docker_e2e_package_mount_args "$PACKAGE_TGZ"
CANDIDATE_KIND="tarball"
CANDIDATE_SPEC="/tmp/openclaw-current.tgz"
else
CANDIDATE_KIND="npm"
CANDIDATE_SPEC="$(normalize_npm_candidate "$CANDIDATE_RAW")"
fi
OPENCLAW_TEST_STATE_FUNCTION_B64="$(docker_e2e_test_state_function_b64)"
docker_e2e_build_or_reuse "$IMAGE_NAME" upgrade-survivor "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD"
echo "Running published upgrade survivor Docker E2E..."
docker_e2e_run_with_harness \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e OPENCLAW_TEST_STATE_FUNCTION_B64="$OPENCLAW_TEST_STATE_FUNCTION_B64" \
-e OPENCLAW_UPGRADE_SURVIVOR_BASELINE="$BASELINE_SPEC" \
-e OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_KIND="$CANDIDATE_KIND" \
-e OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_SPEC="$CANDIDATE_SPEC" \
-e OPENCLAW_UPGRADE_SURVIVOR_SUMMARY_JSON=/tmp/openclaw-upgrade-survivor-artifacts/summary.json \
-e OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS="${OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS:-90}" \
-e OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS="${OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS:-30}" \
-v "$ARTIFACT_DIR:/tmp/openclaw-upgrade-survivor-artifacts" \
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
"$IMAGE_NAME" \
timeout "$DOCKER_RUN_TIMEOUT" bash scripts/e2e/lib/upgrade-survivor/run.sh
exit 0
fi
PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz upgrade-survivor "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}")"
docker_e2e_package_mount_args "$PACKAGE_TGZ"
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 upgrade-survivor upgrade-survivor)"
mkdir -p "$ARTIFACT_DIR"
docker_e2e_build_or_reuse "$IMAGE_NAME" upgrade-survivor "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD"
@@ -22,9 +102,10 @@ echo "Running upgrade survivor Docker E2E..."
docker_e2e_run_with_harness \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e OPENCLAW_TEST_STATE_SCRIPT_B64="$OPENCLAW_TEST_STATE_SCRIPT_B64" \
-e OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT=/tmp/openclaw-upgrade-survivor-artifacts \
-e OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS="${OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS:-90}" \
-e OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS="${OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS:-30}" \
-e OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC="$BASELINE_SPEC" \
-v "$ARTIFACT_DIR:/tmp/openclaw-upgrade-survivor-artifacts" \
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
"$IMAGE_NAME" \
timeout "$DOCKER_RUN_TIMEOUT" bash -lc 'set -euo pipefail
@@ -33,9 +114,16 @@ source scripts/lib/openclaw-e2e-instance.sh
export npm_config_loglevel=error
export npm_config_fund=false
export npm_config_audit=false
export npm_config_prefix=/tmp/npm-prefix
export NPM_CONFIG_PREFIX=/tmp/npm-prefix
export PATH="/tmp/npm-prefix/bin:$PATH"
export OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT="${OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT:-/tmp/openclaw-upgrade-survivor-artifacts}"
mkdir -p "$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT"
export TMPDIR="$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT/tmp"
export OPENCLAW_TEST_STATE_TMPDIR="$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT/state-tmp"
export npm_config_prefix="$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT/npm-prefix"
export NPM_CONFIG_PREFIX="$npm_config_prefix"
export npm_config_cache="$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT/npm-cache"
export npm_config_tmp="$TMPDIR"
mkdir -p "$TMPDIR" "$OPENCLAW_TEST_STATE_TMPDIR" "$npm_config_prefix" "$npm_config_cache"
export PATH="$npm_config_prefix/bin:$PATH"
export CI=true
export OPENCLAW_NO_ONBOARD=1
export OPENCLAW_NO_PROMPT=1
@@ -56,18 +144,9 @@ trap cleanup EXIT
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
node scripts/e2e/lib/upgrade-survivor/assertions.mjs seed
if [ -n "${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-}" ]; then
echo "Installing published upgrade survivor baseline: ${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC}"
if ! npm install -g --prefix /tmp/npm-prefix "$OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC" --no-fund --no-audit >/tmp/openclaw-upgrade-survivor-install.log 2>&1; then
echo "npm install failed for upgrade survivor baseline" >&2
cat /tmp/openclaw-upgrade-survivor-install.log >&2 || true
exit 1
fi
else
openclaw_e2e_install_package /tmp/openclaw-upgrade-survivor-install.log "upgrade survivor package" /tmp/npm-prefix
fi
openclaw_e2e_install_package "$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT/install.log" "upgrade survivor package" "$npm_config_prefix"
command -v openclaw >/dev/null
package_version="$(node -p "JSON.parse(require(\"node:fs\").readFileSync(\"/tmp/npm-prefix/lib/node_modules/openclaw/package.json\", \"utf8\")).version")"
package_version="$(node -p "JSON.parse(require(\"node:fs\").readFileSync(process.argv[1] + \"/lib/node_modules/openclaw/package.json\", \"utf8\")).version" "$npm_config_prefix")"
OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT="$(
node scripts/e2e/lib/package-compat.mjs "$package_version"
)"