fix(update): preserve plugin convergence through package restart (#131062)

* fix(update): preserve plugin convergence during package upgrades

* fix(e2e): isolate upgrade restart auth setup

* fix(e2e): isolate candidate restart config

* fix(doctor): skip repairs for disabled plugins

* fix(e2e): use canonical identity for restart install

* fix(update): release plugin lease before doctor

* fix(e2e): seed upgrade companion installs

* fix(e2e): validate companion install versions

* fix(ci): register upgrade config parking script

* test(plugins): type npm install config fixture
This commit is contained in:
Vincent Koc
2026-08-28 03:26:43 +08:00
committed by GitHub
parent b9d01e7127
commit 30aa2794d9
26 changed files with 1863 additions and 448 deletions
@@ -64,73 +64,6 @@ async function assertNoRequests(baseUrl) {
}
}
function parkPrepublishAuthoredConfig(configPath, snapshotPath) {
if (!configPath || !snapshotPath) {
throw new Error("park-prepublish-auth-config requires <config-path> <snapshot-path>");
}
const authoredConfig = fs.readFileSync(configPath);
const config = JSON.parse(authoredConfig.toString("utf8"));
if (!config || typeof config !== "object" || Array.isArray(config)) {
throw new Error("prepublish auth config must be a JSON object");
}
for (const key of ["plugins", "channels", "gateway"]) {
const value = config[key];
if (value !== undefined && (!value || typeof value !== "object" || Array.isArray(value))) {
throw new Error(`prepublish auth config ${key} must be an object`);
}
}
if (config.plugins?.allow !== undefined && !Array.isArray(config.plugins.allow)) {
throw new Error("prepublish auth config plugins.allow must be an array");
}
if (
config.plugins?.entries !== undefined &&
(!config.plugins.entries ||
typeof config.plugins.entries !== "object" ||
Array.isArray(config.plugins.entries))
) {
throw new Error("prepublish auth config plugins.entries must be an object");
}
if (
config.gateway?.reload !== undefined &&
(!config.gateway.reload ||
typeof config.gateway.reload !== "object" ||
Array.isArray(config.gateway.reload))
) {
throw new Error("prepublish auth config gateway.reload must be an object");
}
if (Array.isArray(config.plugins?.allow)) {
config.plugins.allow = config.plugins.allow.filter((id) => id !== "whatsapp");
}
if (config.plugins?.entries && typeof config.plugins.entries === "object") {
delete config.plugins.entries.whatsapp;
}
if (config.channels && typeof config.channels === "object") {
delete config.channels.whatsapp;
}
config.gateway ??= {};
config.gateway.reload = { ...config.gateway.reload, mode: "off" };
fs.writeFileSync(snapshotPath, authoredConfig, { mode: 0o600 });
replaceFileAtomically(configPath, Buffer.from(`${JSON.stringify(config, null, 2)}\n`));
}
function restorePrepublishAuthoredConfig(configPath, snapshotPath) {
if (!configPath || !snapshotPath) {
throw new Error("restore-prepublish-auth-config requires <config-path> <snapshot-path>");
}
replaceFileAtomically(configPath, fs.readFileSync(snapshotPath));
}
function replaceFileAtomically(filePath, contents) {
const tempPath = `${filePath}.tmp.${process.pid}`;
const mode = fs.statSync(filePath).mode;
try {
fs.writeFileSync(tempPath, contents, { mode });
fs.renameSync(tempPath, filePath);
} finally {
fs.rmSync(tempPath, { force: true });
}
}
function startPrepublishArtifactServer() {
const manifest = JSON.parse(fs.readFileSync(artifactManifestFile, "utf8"));
if (!Array.isArray(manifest.packages) || manifest.packages.length === 0) {
@@ -731,16 +664,6 @@ if (profile === "assert-no-requests") {
return;
}
if (profile === "park-prepublish-auth-config") {
parkPrepublishAuthoredConfig(portFile, artifactManifestFile);
return;
}
if (profile === "restore-prepublish-auth-config") {
restorePrepublishAuthoredConfig(portFile, artifactManifestFile);
return;
}
const fixture = profiles[profile];
if (!fixture || !portFile) {
if (profile === "prepublish-artifacts" && portFile && artifactManifestFile) {
@@ -1,4 +1,5 @@
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
// Assertions for upgrade-survivor E2E scenarios.
import fs from "node:fs";
import path from "node:path";
@@ -36,6 +37,18 @@ const PERSONA_FILES = new Map([
const LEGACY_SESSION_MAIN_ID = "upgrade-main-session";
const LEGACY_SESSION_DIRECT_ID = "upgrade-direct-session";
const LEGACY_SESSION_GROUP_ID = "upgrade-group-session";
const PLUGIN_DECLARED_SURFACE_GROUPS = [
"channels",
"providers",
"tools",
"contracts",
"hooks",
"mcpServers",
"cliCommands",
"cliBackends",
"skills",
"dangerousConfigFlags",
];
function requireEnv(name) {
const value = process.env[name];
@@ -469,8 +482,14 @@ function assertConfigSurvived() {
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;
const stage = process.env.OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE || "survival";
const discordAllowFrom =
stage === "baseline" ? (discord.allowFrom ?? discord.dm?.allowFrom) : discord.allowFrom;
const discordDmPolicy =
stage === "baseline" ? (discord.dmPolicy ?? discord.dm?.policy) : discord.dmPolicy;
if (stage !== "baseline") {
assert(!Object.hasOwn(discord, "dm"), "legacy Discord DM config survived update");
}
assert(discordDmPolicy === "allowlist", "discord DM policy changed");
assert(
Array.isArray(discordAllowFrom) && discordAllowFrom.includes("111111111111111111"),
@@ -1000,7 +1019,7 @@ function assertExternalPluginInstall(records, pluginId, packageName) {
String(record.spec ?? record.resolvedSpec ?? "").startsWith(packageName),
`configured external ${pluginId} plugin npm spec changed`,
);
return;
return packageJson;
}
assert(
record.clawhubPackage === packageName,
@@ -1011,6 +1030,73 @@ function assertExternalPluginInstall(records, pluginId, packageName) {
isPathInside(extensionsRoot, installPath),
`configured external ${pluginId} ClawHub install path outside managed extensions root: ${installPath}`,
);
return packageJson;
}
function pluginInstallIntegrity(record) {
return record.integrity ?? record.npmIntegrity ?? record.clawpackSha256 ?? record.gitCommit;
}
function acceptedSurfaceHash(surface) {
const canonical = Object.fromEntries(
PLUGIN_DECLARED_SURFACE_GROUPS.map((group) => [group, surface[group].toSorted()]),
);
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
}
function assertCompanionPluginConsent(record, pluginId) {
const integrity = pluginInstallIntegrity(record);
assert(
typeof integrity === "string" && integrity.length > 0,
`${pluginId} plugin integrity missing`,
);
assert(
record.acceptedSurface && typeof record.acceptedSurface === "object",
`${pluginId} plugin accepted surface missing`,
);
for (const group of PLUGIN_DECLARED_SURFACE_GROUPS) {
assert(
Array.isArray(record.acceptedSurface[group]),
`${pluginId} plugin accepted surface ${group} missing`,
);
}
assert(
record.acceptedSurfaceHash === acceptedSurfaceHash(record.acceptedSurface),
`${pluginId} plugin consent hash changed`,
);
assert(
record.acceptedSurfaceIntegrity === integrity,
`${pluginId} plugin consent integrity changed`,
);
assert(
typeof record.acceptedSurfaceAt === "string" &&
Number.isFinite(Date.parse(record.acceptedSurfaceAt)),
`${pluginId} plugin consent timestamp missing`,
);
}
function assertCompanionPluginInstalls([expectedVersion]) {
assert(expectedVersion, "assert-companion-installs requires <expected-version>");
const records = readInstalledPluginIndex().installRecords ?? {};
for (const [pluginId, packageName, source] of [
["discord", "@openclaw/discord", "npm"],
["whatsapp", "@openclaw/whatsapp", "clawhub"],
["codex", "@openclaw/codex", "npm"],
]) {
const packageJson = assertExternalPluginInstall(records, pluginId, packageName);
const record = records[pluginId];
assert(record.source === source, `${pluginId} plugin source changed: ${record.source}`);
const installedVersion = source === "clawhub" ? record.version : record.resolvedVersion;
assert(
installedVersion === expectedVersion,
`${pluginId} plugin version changed: ${String(installedVersion)}`,
);
assert(
packageJson.version === expectedVersion,
`${pluginId} installed package version changed: ${String(packageJson.version)}`,
);
assertCompanionPluginConsent(record, pluginId);
}
}
function assertConfiguredPluginInstalls() {
@@ -1229,6 +1315,8 @@ if (command === "list-scenarios") {
} else if (command === "assert-state") {
assertStateSurvived();
assertConfiguredPluginInstalls();
} else if (command === "assert-companion-installs") {
assertCompanionPluginInstalls(process.argv.slice(3));
} else if (command === "assert-status-json") {
assertStatusJson(process.argv.slice(3));
} else if (command === "assert-update-run-self-upgrade") {
@@ -0,0 +1,142 @@
#!/usr/bin/env node
import fs from "node:fs";
function requireObject(value, label) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${label} must be an object`);
}
}
function requirePaths(command, configPath, snapshotPath) {
if (!configPath || !snapshotPath) {
throw new Error(`${command} requires <config-path> <snapshot-path>`);
}
}
function replaceFileAtomically(filePath, contents) {
const tempPath = `${filePath}.tmp.${process.pid}`;
const mode = fs.statSync(filePath).mode;
try {
fs.writeFileSync(tempPath, contents, { mode });
fs.renameSync(tempPath, filePath);
} finally {
fs.rmSync(tempPath, { force: true });
}
}
function snapshotAndReplace(configPath, snapshotPath, authoredConfig, parkedConfig) {
fs.writeFileSync(snapshotPath, authoredConfig, { mode: 0o600 });
fs.chmodSync(snapshotPath, 0o600);
replaceFileAtomically(
configPath,
Buffer.from(`${JSON.stringify(parkedConfig, null, 2)}\n`, "utf8"),
);
}
function parkPrepublish(configPath, snapshotPath) {
requirePaths("park-prepublish", configPath, snapshotPath);
const authoredConfig = fs.readFileSync(configPath);
const config = JSON.parse(authoredConfig.toString("utf8"));
requireObject(config, "prepublish auth config");
for (const key of ["plugins", "channels", "gateway"]) {
const value = config[key];
if (value !== undefined) {
requireObject(value, `prepublish auth config ${key}`);
}
}
if (config.plugins?.allow !== undefined && !Array.isArray(config.plugins.allow)) {
throw new Error("prepublish auth config plugins.allow must be an array");
}
if (config.plugins?.entries !== undefined) {
requireObject(config.plugins.entries, "prepublish auth config plugins.entries");
}
if (config.gateway?.reload !== undefined) {
requireObject(config.gateway.reload, "prepublish auth config gateway.reload");
}
if (Array.isArray(config.plugins?.allow)) {
config.plugins.allow = config.plugins.allow.filter((id) => id !== "whatsapp");
}
if (config.plugins?.entries) {
delete config.plugins.entries.whatsapp;
}
if (config.channels) {
delete config.channels.whatsapp;
}
config.gateway ??= {};
config.gateway.reload = { ...config.gateway.reload, mode: "off" };
snapshotAndReplace(configPath, snapshotPath, authoredConfig, config);
}
function parkRestartProbe(configPath, snapshotPath, rawPort) {
requirePaths("park-restart-probe", configPath, snapshotPath);
const port = Number(rawPort);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("park-restart-probe requires a valid port");
}
const authoredConfig = fs.readFileSync(configPath);
requireObject(JSON.parse(authoredConfig.toString("utf8")), "restart probe config");
snapshotAndReplace(configPath, snapshotPath, authoredConfig, {
plugins: { enabled: false },
gateway: {
port,
mode: "local",
bind: "loopback",
controlUi: { enabled: false },
auth: {
mode: "token",
token: {
source: "env",
provider: "default",
id: "GATEWAY_AUTH_TOKEN_REF",
},
},
reload: { mode: "off" },
},
});
}
function parkCompanionInstall(configPath, snapshotPath) {
requirePaths("park-companion-install", configPath, snapshotPath);
const authoredConfig = fs.readFileSync(configPath);
requireObject(JSON.parse(authoredConfig.toString("utf8")), "companion install config");
snapshotAndReplace(configPath, snapshotPath, authoredConfig, {
plugins: { enabled: false },
});
}
function restore(configPath, snapshotPath) {
requirePaths("restore", configPath, snapshotPath);
const authoredConfig = fs.readFileSync(snapshotPath);
replaceFileAtomically(configPath, authoredConfig);
if (!fs.readFileSync(configPath).equals(authoredConfig)) {
throw new Error("restored config did not match authored bytes");
}
fs.rmSync(snapshotPath);
}
const [command, configPath, snapshotPath, port] = process.argv.slice(2);
try {
switch (command) {
case "park-prepublish":
parkPrepublish(configPath, snapshotPath);
break;
case "park-restart-probe":
parkRestartProbe(configPath, snapshotPath, port);
break;
case "park-companion-install":
parkCompanionInstall(configPath, snapshotPath);
break;
case "restore":
restore(configPath, snapshotPath);
break;
default:
throw new Error(
"usage: config-parking.mjs <park-prepublish|park-restart-probe|park-companion-install|restore> <config-path> <snapshot-path> [port]",
);
}
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
}
+4 -11
View File
@@ -462,8 +462,8 @@ prepublish_auto_auth_enabled() {
park_prepublish_authored_config() {
prepublish_auto_auth_enabled || return 0
node "${OPENCLAW_UPGRADE_SURVIVOR_CLAWHUB_FIXTURE_SERVER:-scripts/e2e/lib/clawhub-fixture-server.cjs}" \
park-prepublish-auth-config "$OPENCLAW_CONFIG_PATH" "$PREPUBLISH_AUTHORED_CONFIG"
node "${OPENCLAW_UPGRADE_SURVIVOR_CONFIG_PARKING_HELPER:-scripts/e2e/lib/upgrade-survivor/config-parking.mjs}" \
park-prepublish "$OPENCLAW_CONFIG_PATH" "$PREPUBLISH_AUTHORED_CONFIG"
}
assert_prepublish_fixture_idle() {
@@ -474,15 +474,8 @@ assert_prepublish_fixture_idle() {
restore_prepublish_authored_config() {
prepublish_auto_auth_enabled || return 0
if ! node "${OPENCLAW_UPGRADE_SURVIVOR_CLAWHUB_FIXTURE_SERVER:-scripts/e2e/lib/clawhub-fixture-server.cjs}" \
restore-prepublish-auth-config "$OPENCLAW_CONFIG_PATH" "$PREPUBLISH_AUTHORED_CONFIG"; then
return 1
fi
if ! cmp -s "$PREPUBLISH_AUTHORED_CONFIG" "$OPENCLAW_CONFIG_PATH"; then
echo "restored prepublish config did not match authored bytes" >&2
return 1
fi
rm -f "$PREPUBLISH_AUTHORED_CONFIG"
node "${OPENCLAW_UPGRADE_SURVIVOR_CONFIG_PARKING_HELPER:-scripts/e2e/lib/upgrade-survivor/config-parking.mjs}" \
restore "$OPENCLAW_CONFIG_PATH" "$PREPUBLISH_AUTHORED_CONFIG"
}
configure_plugin_registry() {
@@ -413,29 +413,85 @@ prepare_update_restart_probe_current_install() {
local log_file="$2"
local command_timeout="${OPENCLAW_UPGRADE_SURVIVOR_COMMAND_TIMEOUT:-900s}"
local doctor_log="${log_file}.doctor"
local authored_config="${log_file}.authored-config"
local parking_helper="${OPENCLAW_UPGRADE_SURVIVOR_CONFIG_PARKING_HELPER:-scripts/e2e/lib/upgrade-survivor/config-parking.mjs}"
local failure_stage=""
local probe_status=0
local restore_status=0
local start_epoch
local ready_epoch
echo "Preparing candidate-auth gateway for automatic update restart."
install_update_restart_systemctl_shim
seed_update_restart_probe_device_auth
if ! openclaw_e2e_maybe_timeout "$command_timeout" openclaw doctor --fix --non-interactive >"$doctor_log" 2>&1; then
# Service installation persists OPENCLAW_CONFIG_PATH, so isolate the canonical file in place.
# Reload stays off through service setup; restoring authored bytes cannot restart this probe.
node "$parking_helper" \
park-restart-probe "$OPENCLAW_CONFIG_PATH" "$authored_config" "$port" || probe_status=$?
if [ "$probe_status" -ne 0 ]; then
echo "failed to park authored config for candidate restart probe" >&2
if [ -e "$authored_config" ]; then
node "$parking_helper" restore "$OPENCLAW_CONFIG_PATH" "$authored_config" ||
restore_status=$?
fi
if [ "$restore_status" -ne 0 ]; then
return "$restore_status"
fi
return "$probe_status"
fi
# This setup pass migrates candidate device identity while deferring plugin convergence.
# Parent-write support lets that migration persist before the real update begins.
openclaw_e2e_maybe_timeout \
"$command_timeout" \
env \
OPENCLAW_UPDATE_IN_PROGRESS=1 \
OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR=1 \
OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE=1 \
openclaw doctor --fix --non-interactive >"$doctor_log" 2>&1 || {
probe_status=$?
failure_stage="doctor"
}
if [ "$probe_status" -ne 0 ]; then
echo "candidate device identity migration failed" >&2
cat "$doctor_log" >&2 || true
return 1
fi
start_epoch="$(node -e "process.stdout.write(String(Date.now()))")"
env -u OPENCLAW_GATEWAY_TOKEN -u OPENCLAW_GATEWAY_PASSWORD openclaw gateway --port "$port" --bind loopback --allow-unconfigured >"$log_file" 2>&1 &
gateway_pid="$!"
printf '%s\n' "$gateway_pid" >"$OPENCLAW_UPGRADE_SURVIVOR_SYSTEMCTL_SHIM_PID_FILE"
openclaw_e2e_wait_gateway_ready "$gateway_pid" "$log_file" 360 "$port"
ready_epoch="$(node -e "process.stdout.write(String(Date.now()))")"
start_seconds=$(((ready_epoch - start_epoch + 999) / 1000))
write_update_restart_service_auth_env
if ! openclaw_e2e_maybe_timeout "$command_timeout" env -u OPENCLAW_GATEWAY_TOKEN -u OPENCLAW_GATEWAY_PASSWORD openclaw gateway install --force --json >"$OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SERVICE_INSTALL_JSON" 2>"$OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SERVICE_INSTALL_ERR"; then
if [ "$probe_status" -eq 0 ]; then
start_epoch="$(node -e "process.stdout.write(String(Date.now()))")"
env -u OPENCLAW_GATEWAY_TOKEN -u OPENCLAW_GATEWAY_PASSWORD openclaw gateway --port "$port" --bind loopback --allow-unconfigured >"$log_file" 2>&1 &
gateway_pid="$!"
printf '%s\n' "$gateway_pid" >"$OPENCLAW_UPGRADE_SURVIVOR_SYSTEMCTL_SHIM_PID_FILE"
openclaw_e2e_wait_gateway_ready "$gateway_pid" "$log_file" 360 "$port" || {
probe_status=$?
failure_stage="readiness"
}
fi
if [ "$probe_status" -eq 0 ]; then
ready_epoch="$(node -e "process.stdout.write(String(Date.now()))")"
start_seconds=$(((ready_epoch - start_epoch + 999) / 1000))
write_update_restart_service_auth_env || {
probe_status=$?
failure_stage="service-env"
}
fi
if [ "$probe_status" -eq 0 ]; then
openclaw_e2e_maybe_timeout "$command_timeout" env -u OPENCLAW_GATEWAY_TOKEN -u OPENCLAW_GATEWAY_PASSWORD openclaw gateway install --force --json >"$OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SERVICE_INSTALL_JSON" 2>"$OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SERVICE_INSTALL_ERR" || {
probe_status=$?
failure_stage="install"
}
fi
if [ "$failure_stage" = "install" ]; then
echo "gateway service install failed" >&2
cat "$OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SERVICE_INSTALL_ERR" >&2 || true
cat "$OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SERVICE_INSTALL_JSON" >&2 || true
return 1
elif [ "$failure_stage" = "readiness" ]; then
echo "candidate restart probe gateway did not become ready" >&2
elif [ "$failure_stage" = "service-env" ]; then
echo "failed to write candidate restart service environment" >&2
fi
node "$parking_helper" restore "$OPENCLAW_CONFIG_PATH" "$authored_config" || restore_status=$?
if [ "$restore_status" -ne 0 ]; then
echo "failed to restore authored config after candidate restart probe" >&2
return "$restore_status"
fi
return "$probe_status"
}
+91 -43
View File
@@ -10,6 +10,7 @@ DOCKER_E2E_HARNESS_ROOT_DIR="$HARNESS_ROOT_DIR"
source "$HARNESS_ROOT_DIR/scripts/lib/docker-e2e-image.sh"
source "$HARNESS_ROOT_DIR/scripts/lib/docker-e2e-package.sh"
source "$HARNESS_ROOT_DIR/scripts/lib/openclaw-e2e-instance.sh"
source "$HARNESS_ROOT_DIR/scripts/e2e/lib/prepublish-plugin-registry.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}"
@@ -91,7 +92,7 @@ LANE_ARTIFACT_SUFFIX="$(resolve_lane_artifact_suffix)"
LANE_ARTIFACT_SUFFIX="${LANE_ARTIFACT_SUFFIX//[^A-Za-z0-9_.-]/_}"
ARTIFACT_DIR="${OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_DIR:-$ROOT_DIR/.artifacts/upgrade-survivor/$LANE_ARTIFACT_SUFFIX}"
DOCKER_RUN_USER_ARGS=()
PREPUBLISH_PLUGIN_REGISTRY_ARGS=()
OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DOCKER_ARGS=()
AUTO_PREPUBLISH_PLUGIN_REGISTRY_ROOT=""
PROBE_ENV_ARGS=(
-e OPENCLAW_UPGRADE_SURVIVOR_PROBE_TIMEOUT_MS="$PROBE_TIMEOUT_MS"
@@ -108,34 +109,9 @@ if [ -n "${OPENCLAW_UPGRADE_SURVIVOR_READYZ_ALLOW_DEGRADED:-}" ]; then
-e OPENCLAW_UPGRADE_SURVIVOR_READYZ_ALLOW_DEGRADED="$OPENCLAW_UPGRADE_SURVIVOR_READYZ_ALLOW_DEGRADED"
)
fi
configure_prepublish_plugin_registry() {
local registry_dir="$1"
PREPUBLISH_PLUGIN_REGISTRY_DIR="$(
cd "$registry_dir" && pwd
)"
local manifest="$PREPUBLISH_PLUGIN_REGISTRY_DIR/prepublish-plugin-registry.json"
if [ ! -f "$manifest" ]; then
echo "Prepublish plugin registry manifest is missing." >&2
exit 1
fi
local source_sha="${OPENCLAW_DOCKER_E2E_SELECTED_SHA:-}"
local candidate_version="${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_CANDIDATE_VERSION:-}"
local manifest_sha256="${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_MANIFEST_SHA256:-}"
source_sha="${source_sha:-$(node -e 'process.stdout.write(require(process.argv[1]).sourceSha)' "$manifest")}"
candidate_version="${candidate_version:-$(node -e 'process.stdout.write(require(process.argv[1]).candidateVersion)' "$manifest")}"
if [ -z "$manifest_sha256" ]; then
manifest_sha256="$(node -e 'const fs=require("node:fs"),crypto=require("node:crypto");process.stdout.write(crypto.createHash("sha256").update(fs.readFileSync(process.argv[1])).digest("hex"))' "$manifest")"
fi
PREPUBLISH_PLUGIN_REGISTRY_ARGS=(
-e OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR=/tmp/openclaw-prepublish-plugin-registry
-e OPENCLAW_DOCKER_E2E_SELECTED_SHA="$source_sha"
-e OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_CANDIDATE_VERSION="$candidate_version"
-e OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_MANIFEST_SHA256="$manifest_sha256"
-v "$PREPUBLISH_PLUGIN_REGISTRY_DIR:/tmp/openclaw-prepublish-plugin-registry:ro"
)
}
if [ -n "${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR:-}" ]; then
configure_prepublish_plugin_registry "$OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR"
openclaw_prepublish_plugin_registry_configure_docker_args \
"$OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR"
fi
cleanup_outer() {
docker_e2e_cleanup_package_tgz "${PACKAGE_TGZ:-}"
@@ -223,7 +199,7 @@ if [ "${OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE:-0}" = "1" ]; then
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS="$BASELINE_SPEC" \
OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS="$SCENARIO" \
node "$HARNESS_ROOT_DIR/scripts/test-docker-all.mjs" --prepare-plugin-registry
configure_prepublish_plugin_registry \
openclaw_prepublish_plugin_registry_configure_docker_args \
"$AUTO_PREPUBLISH_PLUGIN_REGISTRY_ROOT/prepublish-plugin-registry"
fi
@@ -260,13 +236,15 @@ if [ "${OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE:-0}" = "1" ]; then
-e OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS="$START_BUDGET_SECONDS" \
-e OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS="$STATUS_BUDGET_SECONDS" \
-e OPENCLAW_UPGRADE_SURVIVOR_CLAWHUB_FIXTURE_SERVER=/tmp/openclaw-clawhub-fixture-server.cjs \
-e OPENCLAW_UPGRADE_SURVIVOR_CONFIG_PARKING_HELPER=/tmp/openclaw-config-parking.mjs \
"${PROBE_ENV_ARGS[@]}" \
${LIVE_OPENAI_ENV_ARGS[@]+"${LIVE_OPENAI_ENV_ARGS[@]}"} \
-v "$ARTIFACT_DIR:/tmp/openclaw-upgrade-survivor-artifacts" \
-v "$TRUSTED_TSX_NODE_MODULES:/tmp/openclaw-release-harness/node_modules:ro" \
-v "$HARNESS_ROOT_DIR/scripts/e2e/lib/clawhub-fixture-server.cjs:/tmp/openclaw-clawhub-fixture-server.cjs:ro" \
-v "$HARNESS_ROOT_DIR/scripts/e2e/lib/upgrade-survivor/config-parking.mjs:/tmp/openclaw-config-parking.mjs:ro" \
-v "$HARNESS_ROOT_DIR/scripts/e2e/lib/upgrade-survivor/run.sh:/tmp/openclaw-upgrade-survivor-run.sh:ro" \
${PREPUBLISH_PLUGIN_REGISTRY_ARGS[@]+"${PREPUBLISH_PLUGIN_REGISTRY_ARGS[@]}"} \
${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DOCKER_ARGS[@]+"${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DOCKER_ARGS[@]}"} \
${DOCKER_E2E_PACKAGE_ARGS[@]+"${DOCKER_E2E_PACKAGE_ARGS[@]}"} \
${DOCKER_RUN_USER_ARGS[@]+"${DOCKER_RUN_USER_ARGS[@]}"} \
"$IMAGE_NAME" \
@@ -276,7 +254,23 @@ 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)"
if [ -z "${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR:-}" ]; then
AUTO_PREPUBLISH_PLUGIN_REGISTRY_ROOT="$(
mktemp -d "${TMPDIR:-/tmp}/openclaw-upgrade-survivor-plugin-registry.XXXXXX"
)"
planner_lane="upgrade-survivor"
if [ "$UPDATE_RESTART_MODE" = "auto-auth" ]; then
planner_lane="update-restart-auth"
fi
OPENCLAW_DOCKER_ALL_LANES="$planner_lane" \
OPENCLAW_DOCKER_ALL_LOG_DIR="$AUTO_PREPUBLISH_PLUGIN_REGISTRY_ROOT" \
OPENCLAW_DOCKER_ALL_TIMINGS=0 \
OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS="$SCENARIO" \
node "$HARNESS_ROOT_DIR/scripts/test-docker-all.mjs" --prepare-plugin-registry
openclaw_prepublish_plugin_registry_configure_docker_args \
"$AUTO_PREPUBLISH_PLUGIN_REGISTRY_ROOT/prepublish-plugin-registry"
fi
OPENCLAW_TEST_STATE_FUNCTION_B64="$(docker_e2e_test_state_function_b64)"
mkdir -p "$ARTIFACT_DIR"
chmod -R a+rwX "$ARTIFACT_DIR" || true
@@ -285,7 +279,7 @@ docker_e2e_build_or_reuse "$IMAGE_NAME" upgrade-survivor "$ROOT_DIR/scripts/e2e/
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_TEST_STATE_FUNCTION_B64="$OPENCLAW_TEST_STATE_FUNCTION_B64" \
-e OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT=/tmp/openclaw-upgrade-survivor-artifacts \
-e OPENCLAW_UPGRADE_SURVIVOR_ROOT_MANAGED_VPS="$ROOT_MANAGED_VPS" \
-e OPENCLAW_UPGRADE_SURVIVOR_SCENARIO="$SCENARIO" \
@@ -294,10 +288,12 @@ docker_e2e_run_with_harness \
-e OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS="$START_BUDGET_SECONDS" \
-e OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS="$STATUS_BUDGET_SECONDS" \
-e OPENCLAW_UPGRADE_SURVIVOR_CLAWHUB_FIXTURE_SERVER=/tmp/openclaw-clawhub-fixture-server.cjs \
-e OPENCLAW_UPGRADE_SURVIVOR_CONFIG_PARKING_HELPER=/tmp/openclaw-config-parking.mjs \
"${PROBE_ENV_ARGS[@]}" \
-v "$ARTIFACT_DIR:/tmp/openclaw-upgrade-survivor-artifacts" \
-v "$HARNESS_ROOT_DIR/scripts/e2e/lib/clawhub-fixture-server.cjs:/tmp/openclaw-clawhub-fixture-server.cjs:ro" \
"${PREPUBLISH_PLUGIN_REGISTRY_ARGS[@]}" \
-v "$HARNESS_ROOT_DIR/scripts/e2e/lib/upgrade-survivor/config-parking.mjs:/tmp/openclaw-config-parking.mjs:ro" \
"${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DOCKER_ARGS[@]}" \
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
"${DOCKER_RUN_USER_ARGS[@]}" \
"$IMAGE_NAME" \
@@ -331,10 +327,13 @@ 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"
if [ "${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-base}" = "feishu-channel" ]; then
SCENARIO="${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-base}"
if [ "$SCENARIO" = "feishu-channel" ]; then
export FEISHU_APP_SECRET="upgrade-survivor-feishu-secret"
fi
export BRAVE_API_KEY="BSA_upgrade_survivor_brave_key"
if [ "$SCENARIO" = "configured-plugin-installs" ] || [ "$SCENARIO" = "sqlite-volume" ]; then
export BRAVE_API_KEY="BSA_upgrade_survivor_brave_key"
fi
UPDATE_RESTART_MODE="${OPENCLAW_UPGRADE_SURVIVOR_UPDATE_RESTART_MODE:-manual}"
command_timeout="${OPENCLAW_UPGRADE_SURVIVOR_COMMAND_TIMEOUT:-900s}"
@@ -462,7 +461,7 @@ NODE
[ -n "${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR:-}" ] || return 0
fi
openclaw_prepublish_plugin_registry_start \
openclaw_prepublish_plugin_registry_start \
"${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR:-}" \
"${OPENCLAW_DOCKER_E2E_SELECTED_SHA:-}" \
"$package_version" \
@@ -472,7 +471,60 @@ NODE
"${registry_args[@]}"
}
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
install_companion_plugins() {
local authored_config="$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT/companion-install-authored.json"
local install_status=0
local restore_status=0
node "$OPENCLAW_UPGRADE_SURVIVOR_CONFIG_PARKING_HELPER" \
park-companion-install "$OPENCLAW_CONFIG_PATH" "$authored_config"
set +e
openclaw plugins install "npm:@openclaw/discord@$package_version" --pin --accept-capabilities
install_status=$?
if [ "$install_status" -eq 0 ]; then
openclaw plugins install "clawhub:@openclaw/whatsapp@$package_version" --accept-capabilities
install_status=$?
fi
if [ "$install_status" -eq 0 ]; then
node "$OPENCLAW_UPGRADE_SURVIVOR_CLAWHUB_FIXTURE_SERVER" \
assert-prepublish-requests "$OPENCLAW_CLAWHUB_URL" "@openclaw/whatsapp" "$package_version"
install_status=$?
fi
if [ "$install_status" -eq 0 ]; then
openclaw plugins install "npm:@openclaw/codex@$package_version" --pin --accept-capabilities
install_status=$?
fi
node "$OPENCLAW_UPGRADE_SURVIVOR_CONFIG_PARKING_HELPER" \
restore "$OPENCLAW_CONFIG_PATH" "$authored_config"
restore_status=$?
set -e
if [ "$install_status" -ne 0 ]; then
return "$install_status"
fi
if [ "$restore_status" -ne 0 ]; then
return "$restore_status"
fi
node scripts/e2e/lib/upgrade-survivor/assertions.mjs \
assert-companion-installs "$package_version"
}
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_FUNCTION_B64:?missing OPENCLAW_TEST_STATE_FUNCTION_B64}"
if [ "$UPDATE_RESTART_MODE" = "auto-auth" ]; then
account_home="$(getent passwd "$(id -u)" | cut -d: -f6)"
if [ -z "$account_home" ]; then
echo "Could not resolve the current account home" >&2
exit 1
fi
openclaw_test_state_create "$account_home" upgrade-survivor
export HOME="$account_home"
export USERPROFILE="$account_home"
export OPENCLAW_STATE_DIR="$account_home/.openclaw"
export OPENCLAW_CONFIG_PATH="$OPENCLAW_STATE_DIR/openclaw.json"
unset OPENCLAW_HOME
else
openclaw_test_state_create upgrade-survivor upgrade-survivor
fi
node scripts/e2e/lib/upgrade-survivor/assertions.mjs seed
openclaw_e2e_install_package "$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT/install.log" "upgrade survivor package" "$npm_config_prefix"
@@ -487,13 +539,14 @@ echo "Checking dirty-state config before update..."
OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE=baseline node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-config
OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE=baseline node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-state
configure_clawhub_fixture
configure_plugin_registry
install_companion_plugins
if [ "$UPDATE_RESTART_MODE" = "auto-auth" ]; then
# shellcheck disable=SC1091
source scripts/e2e/lib/upgrade-survivor/update-restart-auth.sh
prepare_update_restart_probe_current_install "$PORT" "$GATEWAY_LOG"
fi
configure_plugin_registry
echo "Running package update against the mounted tarball..."
update_args=(update --tag "${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" --yes --json)
if [ "$UPDATE_RESTART_MODE" != "auto-auth" ]; then
@@ -514,11 +567,6 @@ if [ "$update_status" -ne 0 ]; then
openclaw_e2e_print_log /tmp/openclaw-upgrade-survivor-update.json >&2 || true
exit "$update_status"
fi
if [ -n "${OPENCLAW_CLAWHUB_URL:-}" ]; then
node "$OPENCLAW_UPGRADE_SURVIVOR_CLAWHUB_FIXTURE_SERVER" \
assert-prepublish-requests "$OPENCLAW_CLAWHUB_URL" "@openclaw/whatsapp" "$package_version"
fi
if [ "$UPDATE_RESTART_MODE" = "auto-auth" ]; then
echo "Skipping doctor repair until after restart proof."
else
@@ -0,0 +1,50 @@
import { join } from "node:path";
import type { LaneBaseParams, LaneState } from "./config.ts";
import { runInstalledCli } from "./installed.ts";
import { runTimedLanePhase } from "./reporting.ts";
import { runOpenClaw } from "./runtime.ts";
export async function installLaneCompanions(
params: Pick<LaneBaseParams, "companions" | "logsDir"> & {
lane: LaneState;
env: NodeJS.ProcessEnv;
cliPath?: string;
},
) {
if (params.companions.length === 0) {
return;
}
await runTimedLanePhase(params.lane, "install-companions", async () => {
for (const companion of params.companions) {
const logPath = join(
params.logsDir,
`companion-${companion.name.replace(/[^a-z0-9]+/giu, "-")}.log`,
);
const args = [
"plugins",
"install",
`npm-pack:${companion.tarballPath}`,
"--force",
"--accept-capabilities",
];
if (params.cliPath) {
await runInstalledCli({
cliPath: params.cliPath,
args,
env: params.env,
cwd: params.lane.homeDir,
logPath,
timeoutMs: 10 * 60 * 1000,
});
continue;
}
await runOpenClaw({
lane: params.lane,
args,
env: params.env,
logPath,
timeoutMs: 10 * 60 * 1000,
});
}
});
}
+1 -39
View File
@@ -55,6 +55,7 @@ import {
waitForInstalledGateway,
waitForInstalledGatewayToStop,
} from "./installed.ts";
import { installLaneCompanions } from "./lane-companions.ts";
import { maybeRunDiscordRoundtrip } from "./network-smokes.ts";
import {
reserveGatewayPortForLane,
@@ -75,45 +76,6 @@ import {
} from "./runtime.ts";
import { formatError, trimForSummary } from "./shared.ts";
async function installLaneCompanions(
params: LaneBaseParams & {
lane: LaneState;
env: NodeJS.ProcessEnv;
cliPath?: string;
},
) {
if (params.companions.length === 0) {
return;
}
await runTimedLanePhase(params.lane, "install-companions", async () => {
for (const companion of params.companions) {
const logPath = join(
params.logsDir,
`companion-${companion.name.replace(/[^a-z0-9]+/giu, "-")}.log`,
);
const args = ["plugins", "install", `npm-pack:${companion.tarballPath}`, "--force"];
if (params.cliPath) {
await runInstalledCli({
cliPath: params.cliPath,
args,
env: params.env,
cwd: params.lane.homeDir,
logPath,
timeoutMs: 10 * 60 * 1000,
});
continue;
}
await runOpenClaw({
lane: params.lane,
args,
env: params.env,
logPath,
timeoutMs: 10 * 60 * 1000,
});
}
});
}
export async function runFreshLane(params: LaneBaseParams & { build: CandidateBuild }) {
const lane = createLaneState("fresh");
const cleanup: Cleanup[] = [];