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
+1
View File
@@ -63,6 +63,7 @@ const repositoryScriptEntries = [
"scripts/e2e/lib/release-user-journey/write-clickclack-plugin.mjs!",
"scripts/e2e/lib/run-with-pty.mjs!",
"scripts/e2e/lib/sandbox-browser-sidecar/scenario.mjs!",
"scripts/e2e/lib/upgrade-survivor/config-parking.mjs!",
"scripts/e2e/lib/upgrade-survivor/probe-gateway.mjs!",
"scripts/embedded-run-abort-leak.ts!",
"scripts/fixtures/packed-plugin-sdk-type-smoke.ts!",
@@ -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[] = [];
+3 -2
View File
@@ -3312,7 +3312,7 @@ describe("update-cli", () => {
},
} as OpenClawConfig;
vi.mocked(readConfigFileSnapshot).mockResolvedValue(configSnapshot(config));
loadInstalledPluginIndexInstallRecords.mockResolvedValueOnce({
loadInstalledPluginIndexInstallRecords.mockResolvedValue({
demo: {
source: "npm",
spec: "@openclaw/demo@1.0.0",
@@ -6574,7 +6574,7 @@ describe("update-cli", () => {
const sourceConfig = {
plugins: {},
} as OpenClawConfig;
loadInstalledPluginIndexInstallRecords.mockResolvedValueOnce(pluginInstallRecords);
loadInstalledPluginIndexInstallRecords.mockResolvedValue(pluginInstallRecords);
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
...baseSnapshot,
sourceConfig,
@@ -7654,6 +7654,7 @@ describe("update-cli", () => {
hash: "post-doctor",
});
vi.mocked(readConfigFileSnapshot)
.mockResolvedValueOnce(preDoctorSnapshot)
.mockResolvedValueOnce(preDoctorSnapshot)
.mockResolvedValueOnce(preDoctorSnapshot)
.mockResolvedValueOnce(postDoctorSnapshot)
+70 -67
View File
@@ -176,52 +176,54 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis
);
const channel = requestedChannel ?? storedChannel ?? effectiveChannel ?? DEFAULT_PACKAGE_CHANNEL;
if (requestedChannel) {
configSnapshot = await persistRequestedUpdateChannel({
configSnapshot,
requestedChannel,
configSnapshot = await withPluginLifecycleLease({}, async () => {
configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true });
return await persistRequestedUpdateChannel({
configSnapshot,
requestedChannel,
});
});
}
const completedPluginUpdate = await withPluginLifecycleLease({}, async () => {
const initialPluginUpdate = await withPrePluginUpdateDoctorEnv(async () => {
await runTimedFinalizePhase({
finalizationStartedAt,
phaseTimings,
phase: "configSnapshot",
run: createUpdateConfigSnapshot,
});
const doctorPreparation = await runTimedFinalizePhase({
finalizationStartedAt,
phaseTimings,
phase: "doctor",
run: async () => {
await doctorCommand(defaultRuntime, {
nonInteractive: true,
repair: true,
yes: opts.yes === true,
});
configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true });
if (requestedChannel) {
configSnapshot = await persistRequestedUpdateChannel({
configSnapshot,
requestedChannel,
});
}
const restoredConfig = restoreDroppedPreUpdateChannels(configSnapshot, preFinalizeConfig);
configSnapshot = restoredConfig.snapshot;
const postDoctorStoredChannel = configSnapshot.valid
? normalizeUpdateChannel(configSnapshot.config.update?.channel)
: null;
const postDoctorChannel =
requestedChannel ??
postDoctorStoredChannel ??
storedChannel ??
effectiveChannel ??
DEFAULT_PACKAGE_CHANNEL;
const pluginInstallRecords = await loadInstalledPluginIndexInstallRecords();
return { restoredConfig, postDoctorChannel, pluginInstallRecords };
},
});
const initialPluginUpdate = await withPrePluginUpdateDoctorEnv(async () => {
await runTimedFinalizePhase({
finalizationStartedAt,
phaseTimings,
phase: "configSnapshot",
run: createUpdateConfigSnapshot,
});
await runTimedFinalizePhase({
finalizationStartedAt,
phaseTimings,
phase: "doctor",
run: async () => {
await doctorCommand(defaultRuntime, {
nonInteractive: true,
repair: true,
yes: opts.yes === true,
});
},
});
return await withPluginLifecycleLease({}, async () => {
configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true });
if (requestedChannel) {
configSnapshot = await persistRequestedUpdateChannel({
configSnapshot,
requestedChannel,
});
}
const restoredConfig = restoreDroppedPreUpdateChannels(configSnapshot, preFinalizeConfig);
configSnapshot = restoredConfig.snapshot;
const postDoctorStoredChannel = configSnapshot.valid
? normalizeUpdateChannel(configSnapshot.config.update?.channel)
: null;
const postDoctorChannel =
requestedChannel ??
postDoctorStoredChannel ??
storedChannel ??
effectiveChannel ??
DEFAULT_PACKAGE_CHANNEL;
const pluginInstallRecords = await loadInstalledPluginIndexInstallRecords();
return await runTimedFinalizePhase({
finalizationStartedAt,
phaseTimings,
@@ -229,10 +231,10 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis
run: async () =>
await updatePluginsAfterCoreUpdate({
root,
channel: doctorPreparation.postDoctorChannel,
channel: postDoctorChannel,
configSnapshot,
configChanged: doctorPreparation.restoredConfig.changed,
restoredAuthoredChannels: doctorPreparation.restoredConfig.authoredChannels,
configChanged: restoredConfig.changed,
restoredAuthoredChannels: restoredConfig.authoredChannels,
opts: {
json: opts.json,
timeout: opts.timeout,
@@ -241,7 +243,7 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis
acknowledgeClawHubRisk: opts.acknowledgeClawHubRisk,
},
timeoutMs: timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS,
pluginInstallRecords: doctorPreparation.pluginInstallRecords,
pluginInstallRecords,
}),
outcome: (result) =>
result.status === "error"
@@ -251,26 +253,27 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis
: "completed",
});
});
return await runTimedFinalizePhase({
finalizationStartedAt,
phaseTimings,
phase: "targetConfigConvergence",
run: async () =>
await completePostCorePluginUpdate({
root,
pluginUpdate: initialPluginUpdate,
freshDoctorRequired: initialPluginUpdate.changed,
yes: opts.yes === true,
json: opts.json === true,
timeoutMs: timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS,
}),
outcome: (result) =>
result.pluginUpdate.status === "error"
? "failed"
: result.pluginUpdate.status === "warning"
? "warning"
: "completed",
});
});
// Fresh doctor acquires this same cross-process lease; completion must run after release.
const completedPluginUpdate = await runTimedFinalizePhase({
finalizationStartedAt,
phaseTimings,
phase: "targetConfigConvergence",
run: async () =>
await completePostCorePluginUpdate({
root,
pluginUpdate: initialPluginUpdate,
freshDoctorRequired: initialPluginUpdate.changed,
yes: opts.yes === true,
json: opts.json === true,
timeoutMs: timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS,
}),
outcome: (result) =>
result.pluginUpdate.status === "error"
? "failed"
: result.pluginUpdate.status === "warning"
? "warning"
: "completed",
});
const pluginUpdate = completedPluginUpdate.pluginUpdate;
configSnapshot = completedPluginUpdate.configSnapshot;
@@ -0,0 +1,215 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { defaultRuntime } from "../../runtime.js";
const mocks = vi.hoisted(() => ({
events: [] as string[],
leaseActive: false,
readConfig: vi.fn(),
}));
const validConfigSnapshot = {
valid: true,
parsed: {},
config: {},
runtimeConfig: {},
sourceConfig: {},
warnings: [],
issues: [],
legacyIssues: [],
};
const successfulPluginUpdate = {
status: "ok",
changed: true,
sync: {
changed: false,
switchedToBundled: [],
switchedToNpm: [],
warnings: [],
errors: [],
},
npm: { changed: false, outcomes: [] },
integrityDrifts: [],
warnings: [],
};
function record(name: string): void {
mocks.events.push(`${name}:${mocks.leaseActive}`);
}
vi.mock("../../commands/doctor.js", () => ({
doctorCommand: vi.fn(async () => {
record("doctor");
}),
}));
vi.mock("../../config/config.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../config/config.js")>()),
assertConfigWriteAllowedInCurrentMode: vi.fn(),
readConfigFileSnapshot: mocks.readConfig,
}));
vi.mock("../../plugins/installed-plugin-index-records.js", () => ({
loadInstalledPluginIndexInstallRecords: vi.fn(async () => {
record("installed-records");
return {};
}),
}));
vi.mock("../../plugins/installed-plugin-index-store.js", () => ({
readPersistedInstalledPluginIndex: vi.fn(async () => {
record("persisted-index");
return null;
}),
}));
vi.mock("../../plugins/plugin-lifecycle-lease.js", () => ({
withPluginLifecycleLease: async (_params: unknown, run: () => Promise<unknown>) => {
mocks.events.push("lease-enter:false");
mocks.leaseActive = true;
try {
return await run();
} finally {
mocks.leaseActive = false;
mocks.events.push("lease-exit:false");
}
},
}));
vi.mock("../../state/openclaw-state-db.paths.js", () => ({
resolveOpenClawStateSqlitePath: vi.fn(() => "/tmp/openclaw.sqlite"),
}));
vi.mock("../../state/openclaw-state-ownership.js", () => ({
assertOpenClawStateWriteAllowedAtPath: vi.fn(async () => undefined),
}));
vi.mock("./shared.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./shared.js")>()),
parseTimeoutMsOrExit: vi.fn(() => 1_000),
readPackageVersion: vi.fn(async () => "2026.8.27"),
resolveUpdateRoot: vi.fn(async () => "/tmp/openclaw"),
tryWriteCompletionCache: vi.fn(async () => "completed"),
}));
vi.mock("./update-command-config.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./update-command-config.js")>()),
createUpdateConfigSnapshot: vi.fn(async () => {
record("config-snapshot");
}),
persistRequestedUpdateChannel: vi.fn(async (params: { configSnapshot: unknown }) => {
record("persist-channel");
return params.configSnapshot;
}),
readPostCorePreUpdateSourceConfig: vi.fn(async () => ({
sourceConfig: {},
authoredConfig: {},
})),
restoreDroppedPreUpdateChannels: vi.fn((snapshot: unknown) => {
record("restore-channels");
return {
snapshot,
changed: false,
authoredChannels: [],
};
}),
}));
vi.mock("./update-command-fresh-doctor.js", () => ({
completePostCorePluginUpdate: vi.fn(async () => {
record("complete");
return {
pluginUpdate: successfulPluginUpdate,
configSnapshot: validConfigSnapshot,
};
}),
runUpdateFinalizationDoctorInFreshProcess: vi.fn(async () => {
record("fresh-doctor");
}),
withPrePluginUpdateDoctorEnv: async (run: () => Promise<unknown>) => await run(),
}));
vi.mock("./update-command-plugins.js", () => ({
updatePluginsAfterCoreUpdate: vi.fn(async () => {
record("plugin-update");
return successfulPluginUpdate;
}),
}));
vi.mock("./update-command-post-core.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./update-command-post-core.js")>()),
readPostCorePluginInstallRecordsFile: vi.fn(async () => {
record("handoff-records");
return {};
}),
resolvePostCoreUpdateStartedAtMs: vi.fn(async () => 1_000),
writePostCorePluginUpdateResultFile: vi.fn(async () => undefined),
}));
import { updateFinalizeCommand } from "./update-command-finalize.js";
import { resumePostCoreUpdate } from "./update-command-resume.js";
function expectLifecycleBoundary(doctorEvent: string): void {
const doctorIndex = mocks.events.indexOf(`${doctorEvent}:false`);
expect(doctorIndex).toBeGreaterThan(-1);
expect(mocks.events).not.toContain(`${doctorEvent}:true`);
const authoritativeReadIndex = mocks.events.findIndex(
(event, index) => index > doctorIndex && event === "read-config:true",
);
expect(authoritativeReadIndex).toBeGreaterThan(doctorIndex);
for (const event of [
"persist-channel:true",
"restore-channels:true",
"installed-records:true",
"plugin-update:true",
]) {
expect(mocks.events).toContain(event);
}
expect(mocks.events.indexOf("plugin-update:true")).toBeGreaterThan(authoritativeReadIndex);
const lastLeaseExit = mocks.events.lastIndexOf("lease-exit:false");
expect(mocks.events.indexOf("complete:false")).toBeGreaterThan(lastLeaseExit);
}
describe("update plugin lifecycle lease boundaries", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
mocks.events = [];
mocks.leaseActive = false;
mocks.readConfig.mockImplementation(async () => {
record("read-config");
return validConfigSnapshot;
});
vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined);
vi.spyOn(defaultRuntime, "exit").mockImplementation(() => undefined as never);
vi.spyOn(defaultRuntime, "log").mockImplementation(() => undefined);
vi.spyOn(defaultRuntime, "writeJson").mockImplementation(() => undefined);
});
it("runs resume doctors outside the lease and rereads mutation state after acquisition", async () => {
await resumePostCoreUpdate({
root: "/tmp/openclaw",
channel: "stable",
opts: { yes: true },
timeoutMs: 1_000,
});
expectLifecycleBoundary("fresh-doctor");
expect(mocks.events).toContain("persisted-index:true");
expect(mocks.events).toContain("handoff-records:false");
});
it("runs finalizer doctors outside the lease and rereads mutation state after acquisition", async () => {
await updateFinalizeCommand({
channel: "stable",
deferCompletionCache: true,
json: true,
yes: true,
});
expectLifecycleBoundary("doctor");
const doctorIndex = mocks.events.indexOf("doctor:false");
expect(mocks.events.slice(0, doctorIndex)).toContain("read-config:true");
expect(mocks.events).not.toContain("persisted-index:true");
});
});
@@ -7,6 +7,8 @@ import { defaultRuntime } from "../../runtime.js";
const mocks = vi.hoisted(() => ({
completePluginUpdate: vi.fn(),
leaseActive: false,
loadPluginRecords: vi.fn(),
markSentinelFailure: vi.fn(async () => undefined),
printResult: vi.fn(),
readConfig: vi.fn(),
@@ -30,7 +32,17 @@ vi.mock("../../daemon/service.js", async (importOriginal) => ({
readGatewayServiceState: mocks.readServiceState,
}));
vi.mock("../../plugins/plugin-lifecycle-lease.js", () => ({
withPluginLifecycleLease: async (_params: unknown, callback: () => unknown) => callback(),
withPluginLifecycleLease: async (_params: unknown, callback: () => unknown) => {
mocks.leaseActive = true;
try {
return await callback();
} finally {
mocks.leaseActive = false;
}
},
}));
vi.mock("../../plugins/installed-plugin-index-records.js", () => ({
loadInstalledPluginIndexInstallRecords: mocks.loadPluginRecords,
}));
vi.mock("./update-command-config.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./update-command-config.js")>()),
@@ -223,6 +235,8 @@ describe("retireStandaloneGitWrapper", () => {
describe("successful update finalization ordering", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.leaseActive = false;
mocks.loadPluginRecords.mockResolvedValue({});
mocks.readConfig.mockResolvedValue(validConfigSnapshot);
mocks.updatePlugins.mockResolvedValue(successfulPluginUpdate);
mocks.completePluginUpdate.mockResolvedValue({
@@ -267,6 +281,77 @@ describe("successful update finalization ordering", () => {
}
});
it("releases the plugin lifecycle lease before fresh doctor completion", async () => {
const pluginInstallRecords = {
demo: {
source: "npm",
spec: "@acme/demo",
installPath: "/tmp/demo",
},
};
const ownedManagedUpdateEnv = {
...process.env,
OPENCLAW_LIFECYCLE_TEST_MARKER: "owned",
};
mocks.readConfig.mockImplementationOnce(async () => {
expect(mocks.leaseActive).toBe(true);
expect(process.env.OPENCLAW_LIFECYCLE_TEST_MARKER).toBe("owned");
return validConfigSnapshot;
});
mocks.loadPluginRecords.mockImplementationOnce(async () => {
expect(mocks.leaseActive).toBe(true);
expect(process.env.OPENCLAW_LIFECYCLE_TEST_MARKER).toBe("owned");
return pluginInstallRecords;
});
mocks.updatePlugins.mockImplementationOnce(
async (params: { pluginInstallRecords: unknown }) => {
expect(mocks.leaseActive).toBe(true);
expect(process.env.OPENCLAW_LIFECYCLE_TEST_MARKER).toBe("owned");
expect(params.pluginInstallRecords).toBe(pluginInstallRecords);
return successfulPluginUpdate;
},
);
mocks.completePluginUpdate.mockImplementationOnce(async () => {
expect(mocks.leaseActive).toBe(false);
expect(process.env.OPENCLAW_LIFECYCLE_TEST_MARKER).toBe("owned");
return {
pluginUpdate: successfulPluginUpdate,
configSnapshot: validConfigSnapshot,
};
});
await finishUpdate({
result: {
status: "ok",
mode: "npm",
root: "/tmp/openclaw-update",
steps: [],
durationMs: 1,
},
root: "/tmp/openclaw-update",
installKindChanged: false,
configSnapshot: validConfigSnapshot,
requestedChannel: null,
storedChannel: null,
channel: "stable",
downgradeRisk: false,
shouldRestart: false,
opts: {},
showProgress: false,
ownedManagedUpdateEnv,
controlPlaneUpdateSentinelMeta: {},
preUpdatePluginInstallRecords: {},
startedAt: Date.now(),
updateStepTimeoutMs: 1_000,
} as unknown as FinishUpdateParams);
expect(mocks.readConfig).toHaveBeenCalledOnce();
expect(mocks.loadPluginRecords).toHaveBeenCalledOnce();
expect(mocks.updatePlugins).toHaveBeenCalledOnce();
expect(mocks.completePluginUpdate).toHaveBeenCalledOnce();
expect(mocks.leaseActive).toBe(false);
});
it("marks and prints an error without persisting success when retirement fails", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-finalize-failure-"));
const previousRoot = path.join(home, "old-root");
@@ -243,43 +243,45 @@ export async function finishUpdate(params: {
if (!pluginsUpdatedInFreshProcess) {
await withOwnedManagedUpdateEnv(params.ownedManagedUpdateEnv, async () => {
await withPluginLifecycleLease({}, async () => {
postUpdateConfigSnapshot = await readConfigFileSnapshot({
skipPluginValidation: true,
suppressFutureVersionWarning: shouldResumePostCoreInFreshProcess,
});
postUpdateConfigSnapshot = await persistRequestedUpdateChannel({
configSnapshot: postUpdateConfigSnapshot,
requestedChannel: params.requestedChannel,
});
const restoredConfig = restoreDroppedPreUpdateChannels(
postUpdateConfigSnapshot,
params.configSnapshot.valid
? {
sourceConfig: params.configSnapshot.sourceConfig,
authoredConfig: isRecord(params.configSnapshot.parsed)
? (params.configSnapshot.parsed as OpenClawConfig)
: params.configSnapshot.sourceConfig,
}
: undefined,
);
postUpdateConfigSnapshot = restoredConfig.snapshot;
// Current-process post-core convergence still reports the pre-update
// VERSION. During downgrades, pin compatibility checks to the installed
// target so incompatible newer plugins are disabled before restart.
const postUpdateInstalledVersion = await readPackageVersion(postUpdateRoot);
const versionComparison =
postUpdateInstalledVersion && VERSION
? compareSemverStrings(VERSION, postUpdateInstalledVersion)
: null;
const compatibilityDowngradeTarget =
versionComparison != null && versionComparison > 0 ? postUpdateInstalledVersion : null;
const previousCompatibilityHostVersion = process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION;
if (compatibilityDowngradeTarget) {
process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION = compatibilityDowngradeTarget;
}
try {
const initialPluginUpdate = await updatePluginsAfterCoreUpdate({
const previousCompatibilityHostVersion = process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION;
let compatibilityDowngradeTarget: string | null = null;
try {
const initialPluginUpdate = await withPluginLifecycleLease({}, async () => {
postUpdateConfigSnapshot = await readConfigFileSnapshot({
skipPluginValidation: true,
suppressFutureVersionWarning: shouldResumePostCoreInFreshProcess,
});
postUpdateConfigSnapshot = await persistRequestedUpdateChannel({
configSnapshot: postUpdateConfigSnapshot,
requestedChannel: params.requestedChannel,
});
const restoredConfig = restoreDroppedPreUpdateChannels(
postUpdateConfigSnapshot,
params.configSnapshot.valid
? {
sourceConfig: params.configSnapshot.sourceConfig,
authoredConfig: isRecord(params.configSnapshot.parsed)
? (params.configSnapshot.parsed as OpenClawConfig)
: params.configSnapshot.sourceConfig,
}
: undefined,
);
postUpdateConfigSnapshot = restoredConfig.snapshot;
// Current-process post-core convergence still reports the pre-update
// VERSION. During downgrades, pin compatibility checks to the installed
// target so incompatible newer plugins are disabled before restart.
const postUpdateInstalledVersion = await readPackageVersion(postUpdateRoot);
const versionComparison =
postUpdateInstalledVersion && VERSION
? compareSemverStrings(VERSION, postUpdateInstalledVersion)
: null;
compatibilityDowngradeTarget =
versionComparison != null && versionComparison > 0 ? postUpdateInstalledVersion : null;
if (compatibilityDowngradeTarget) {
process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION = compatibilityDowngradeTarget;
}
const pluginInstallRecords = await loadInstalledPluginIndexInstallRecords();
return await updatePluginsAfterCoreUpdate({
root: postUpdateRoot,
channel: params.channel,
configSnapshot: postUpdateConfigSnapshot,
@@ -287,33 +289,32 @@ export async function finishUpdate(params: {
restoredAuthoredChannels: restoredConfig.authoredChannels,
opts: params.opts,
timeoutMs: params.updateStepTimeoutMs,
pluginInstallRecords: params.preUpdatePluginInstallRecords,
pluginInstallRecords,
});
const completedPluginUpdate = await completePostCorePluginUpdate({
root: postUpdateRoot,
pluginUpdate: initialPluginUpdate,
// Aggregate plugin changes and core install changes independently require fresh doctor.
freshDoctorRequired:
didCoreUpdateChangeInstall(params.result) || initialPluginUpdate.changed,
yes: params.opts.yes === true,
json: params.opts.json === true,
timeoutMs: params.updateStepTimeoutMs,
...(params.packageUpdateNodeRunner
? { nodeRunner: params.packageUpdateNodeRunner }
: {}),
});
postCorePluginUpdate = completedPluginUpdate.pluginUpdate;
postUpdateConfigSnapshot = completedPluginUpdate.configSnapshot;
} finally {
if (compatibilityDowngradeTarget) {
if (previousCompatibilityHostVersion === undefined) {
delete process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION;
} else {
process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION = previousCompatibilityHostVersion;
}
});
// Fresh doctor acquires this same cross-process lease; completion must run after release.
const completedPluginUpdate = await completePostCorePluginUpdate({
root: postUpdateRoot,
pluginUpdate: initialPluginUpdate,
// Aggregate plugin changes and core install changes independently require fresh doctor.
freshDoctorRequired:
didCoreUpdateChangeInstall(params.result) || initialPluginUpdate.changed,
yes: params.opts.yes === true,
json: params.opts.json === true,
timeoutMs: params.updateStepTimeoutMs,
...(params.packageUpdateNodeRunner ? { nodeRunner: params.packageUpdateNodeRunner } : {}),
});
postCorePluginUpdate = completedPluginUpdate.pluginUpdate;
postUpdateConfigSnapshot = completedPluginUpdate.configSnapshot;
} finally {
if (compatibilityDowngradeTarget) {
if (previousCompatibilityHostVersion === undefined) {
delete process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION;
} else {
process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION = previousCompatibilityHostVersion;
}
}
});
}
});
}
+40 -40
View File
@@ -39,10 +39,6 @@ type ResumePostCoreUpdateParams = {
};
export async function resumePostCoreUpdate(params: ResumePostCoreUpdateParams): Promise<void> {
return await withPluginLifecycleLease({}, async () => await resumePostCoreUpdateUnlocked(params));
}
async function resumePostCoreUpdateUnlocked(params: ResumePostCoreUpdateParams): Promise<void> {
if (
params.channel !== "stable" &&
params.channel !== "extended-stable" &&
@@ -53,6 +49,7 @@ async function resumePostCoreUpdateUnlocked(params: ResumePostCoreUpdateParams):
defaultRuntime.exit(1);
return;
}
const channel = params.channel;
const requestedChannelInput = process.env[POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV]?.trim() ?? "";
const requestedChannel = requestedChannelInput
@@ -85,47 +82,50 @@ async function resumePostCoreUpdateUnlocked(params: ResumePostCoreUpdateParams):
json: params.opts.json === true,
timeoutMs: params.timeoutMs,
});
// The fresh process owns the updated migration contracts. Repair before
// plugin convergence writes config, or newly retired plugin keys can block
// the update before doctor gets a chance to migrate them.
configSnapshot = await readConfigFileSnapshot({
skipPluginValidation: true,
suppressFutureVersionWarning: true,
});
configSnapshot = await persistRequestedUpdateChannel({
configSnapshot,
requestedChannel,
});
const restoredConfig = restoreDroppedPreUpdateChannels(configSnapshot, preUpdateSourceConfig);
const parentPluginInstallRecords = await readPostCorePluginInstallRecordsFile(
process.env[POST_CORE_UPDATE_INSTALL_RECORDS_PATH_ENV],
);
// The updated doctor may have repaired or removed plugin installs before this process resumed.
const currentPluginInstallRecords = await loadInstalledPluginIndexInstallRecords();
const persistedPluginIndex = await readPersistedInstalledPluginIndex();
const hasForwardedUpdateStart = Boolean(process.env[POST_CORE_UPDATE_STARTED_AT_ENV]?.trim());
const currentIndexIsAuthoritative =
Object.keys(currentPluginInstallRecords).length > 0 ||
Boolean(
persistedPluginIndex &&
hasForwardedUpdateStart &&
updateStartedAtMs !== undefined &&
persistedPluginIndex.generatedAtMs >= updateStartedAtMs,
);
const pluginInstallRecords = currentIndexIsAuthoritative
? currentPluginInstallRecords
: parentPluginInstallRecords;
const initialPluginUpdate = await withPluginLifecycleLease({}, async () => {
// The fresh process owns the updated migration contracts. Repair before
// plugin convergence writes config, or newly retired plugin keys can block
// the update before doctor gets a chance to migrate them.
configSnapshot = await readConfigFileSnapshot({
skipPluginValidation: true,
suppressFutureVersionWarning: true,
});
configSnapshot = await persistRequestedUpdateChannel({
configSnapshot,
requestedChannel,
});
const restoredConfig = restoreDroppedPreUpdateChannels(configSnapshot, preUpdateSourceConfig);
// The updated doctor may have repaired or removed plugin installs before this process resumed.
const currentPluginInstallRecords = await loadInstalledPluginIndexInstallRecords();
const persistedPluginIndex = await readPersistedInstalledPluginIndex();
const hasForwardedUpdateStart = Boolean(process.env[POST_CORE_UPDATE_STARTED_AT_ENV]?.trim());
const currentIndexIsAuthoritative =
Object.keys(currentPluginInstallRecords).length > 0 ||
Boolean(
persistedPluginIndex &&
hasForwardedUpdateStart &&
updateStartedAtMs !== undefined &&
persistedPluginIndex.generatedAtMs >= updateStartedAtMs,
);
const pluginInstallRecords = currentIndexIsAuthoritative
? currentPluginInstallRecords
: parentPluginInstallRecords;
const initialPluginUpdate = await updatePluginsAfterCoreUpdate({
root: params.root,
channel: params.channel,
configSnapshot: restoredConfig.snapshot,
configChanged: restoredConfig.changed,
restoredAuthoredChannels: restoredConfig.authoredChannels,
opts: params.opts,
timeoutMs: params.timeoutMs,
pluginInstallRecords,
return await updatePluginsAfterCoreUpdate({
root: params.root,
channel,
configSnapshot: restoredConfig.snapshot,
configChanged: restoredConfig.changed,
restoredAuthoredChannels: restoredConfig.authoredChannels,
opts: params.opts,
timeoutMs: params.timeoutMs,
pluginInstallRecords,
});
});
// Fresh doctor acquires this same cross-process lease; completion must run after release.
const { pluginUpdate } = await completePostCorePluginUpdate({
root: params.root,
pluginUpdate: initialPluginUpdate,
@@ -248,6 +248,7 @@ async function repairMissingPluginInstallsWithLease(
},
},
pluginIds: missingRecordedPluginIds,
skipDisabledPlugins: true,
updateChannel,
coreVersion: resolveCompatibilityHostVersion(env),
logger: {
@@ -2366,6 +2366,34 @@ describe("repairMissingConfiguredPluginInstalls", () => {
});
it("does not install configured plugins when plugins are globally disabled", async () => {
const records = {
brave: {
source: "npm" as const,
spec: "@openclaw/brave-plugin",
installPath: "/tmp/openclaw-plugins/brave",
},
discord: {
source: "npm" as const,
spec: "@openclaw/discord",
installPath: "/tmp/openclaw-plugins/discord",
},
};
mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records);
mocks.loadPluginMetadataSnapshot.mockReturnValue({
plugins: [],
diagnostics: [
...brokenPluginSnapshot("brave").diagnostics,
...brokenPluginSnapshot("discord").diagnostics,
],
});
mocks.updateNpmInstalledPlugins.mockResolvedValue({
changed: false,
config: { plugins: { installs: records } },
outcomes: [
{ pluginId: "brave", status: "skipped", message: "disabled" },
{ pluginId: "discord", status: "skipped", message: "disabled" },
],
});
mocks.listChannelPluginCatalogEntries.mockReturnValue([
{
id: "matrix",
@@ -2417,10 +2445,15 @@ describe("repairMissingConfiguredPluginInstalls", () => {
env: {},
});
expect(mocks.updateNpmInstalledPlugins).toHaveBeenCalledWith(
expect.objectContaining({
pluginIds: ["brave", "discord"],
skipDisabledPlugins: true,
}),
);
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled();
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
expect(result).toEqual({ changes: [], warnings: [], records: {} });
expect(result).toEqual({ changes: [], warnings: [], records });
});
it("does not install plugins merely listed in plugins.allow", async () => {
@@ -1,4 +1,5 @@
import { isExperimentalClawsEnabled } from "../claws/experimental.js";
import { shouldDeferConfiguredPluginInstallRepair } from "../commands/doctor/shared/update-phase.js";
import { hasActiveGatewayExecCredential } from "./doctor-gateway-exec-credential.js";
import { runCoreHealthFindingNote } from "./doctor-health-contribution-core.js";
import {
@@ -103,7 +104,10 @@ export function resolveFinalDoctorHealthContributions(params: {
id: CHANNEL_PACKAGE_STATE_CAPABILITIES_CHECK_ID,
description: "Declared channel package-state checker modules must load.",
defaultEnabled: true,
async detect() {
async detect(ctx) {
if (shouldDeferConfiguredPluginInstallRepair(ctx.env ?? process.env)) {
return [];
}
const { collectBundledChannelPackageStateLoadFailures } =
await import("../channels/plugins/package-state-probes.js");
return collectBundledChannelPackageStateLoadFailures().map((failure) => ({
@@ -3202,6 +3202,48 @@ describe("doctor health contributions", () => {
});
});
it("defers channel package-state loading only until post-core plugin convergence", async () => {
const contribution = requireDoctorContribution("doctor:channel-package-state-capabilities");
mocks.collectBundledChannelPackageStateLoadFailures.mockReturnValue([
{
detail: "plugin module path not found: /plugins/example-chat/auth-presence",
metadataKey: "persistedAuthState",
pluginId: "example-chat",
},
]);
mocks.runDoctorHealthRepairs.mockImplementation(async (ctx, options) => {
const findings = await options.checks[0]!.detect(ctx);
return {
config: ctx.cfg,
findings,
remainingFindings: findings,
changes: [],
warnings: [],
diffs: [],
effects: [],
checksRun: 1,
checksRepaired: 0,
checksValidated: 0,
};
});
vi.stubEnv("OPENCLAW_UPDATE_IN_PROGRESS", "1");
vi.stubEnv("OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR", "1");
const ctx = createDoctorContext();
await contribution.run(ctx);
expect(mocks.collectBundledChannelPackageStateLoadFailures).not.toHaveBeenCalled();
vi.stubEnv("OPENCLAW_UPDATE_POST_CORE_CONVERGENCE", "1");
await contribution.run(ctx);
expect(mocks.collectBundledChannelPackageStateLoadFailures).toHaveBeenCalledOnce();
expect(ctx.runtime.log).toHaveBeenCalledWith(
expect.stringContaining("core/doctor/channel-package-state-capabilities"),
);
});
it("keeps channel preview warnings opt-in for default lint selection", async () => {
const contribution = requireDoctorContribution("doctor:startup-channel-maintenance");
expect(contribution.healthCheckIds).toEqual([
+31 -1
View File
@@ -217,7 +217,7 @@ function createNpmInstallConfig(params: {
resolvedName?: string;
resolvedSpec?: string;
resolvedVersion?: string;
}) {
}): OpenClawConfig {
return {
plugins: {
installs: {
@@ -2949,6 +2949,36 @@ describe("updateNpmInstalledPlugins", () => {
]);
});
it("skips globally disabled installs before network or capability consent", async () => {
capabilityConsentMode.real = true;
const onCapabilityConsent = vi.fn();
const config = createNpmInstallConfig({
pluginId: "demo",
spec: "@acme/demo",
installPath: "/tmp/demo",
});
config.plugins = { ...config.plugins, enabled: false };
const result = await updateNpmInstalledPlugins({
config,
skipDisabledPlugins: true,
onCapabilityConsent,
});
expect(runCommandWithTimeoutMock).not.toHaveBeenCalled();
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
expect(onCapabilityConsent).not.toHaveBeenCalled();
expect(result.changed).toBe(false);
expect(result.config).toBe(config);
expect(result.outcomes).toEqual([
{
pluginId: "demo",
status: "skipped",
message: 'Skipping "demo" (plugins disabled).',
},
]);
});
it("updates disabled trusted official npm installs from the channel spec when requested", async () => {
const installPath = createInstalledPackageDir({
name: "@openclaw/codex",
@@ -165,64 +165,6 @@ describe("ClawHub fixture server", () => {
expect(emptyAssertion.stderr).toContain("assert-no-requests requires <base-url>");
});
it("parks WhatsApp startup config and restores the authored bytes exactly", () => {
const root = tempDirs.make("openclaw-clawhub-auth-config-");
const configPath = path.join(root, "openclaw.json");
const snapshotPath = path.join(root, "openclaw.authored.json");
const authoredConfig = `{
"gateway": { "mode": "local", "reload": { "mode": "hybrid" } },
"plugins": {
"allow": ["discord", "whatsapp"],
"entries": { "discord": { "enabled": true }, "whatsapp": { "enabled": true } }
},
"channels": { "discord": { "enabled": true }, "whatsapp": { "enabled": true } }
}
`;
writeFileSync(configPath, authoredConfig);
const park = spawnSync(
process.execPath,
[SCRIPT_PATH, "park-prepublish-auth-config", configPath, snapshotPath],
{ encoding: "utf8", env: { ...process.env } },
);
expect(park.status, park.stderr).toBe(0);
expect(readFileSync(snapshotPath, "utf8")).toBe(authoredConfig);
expect(JSON.parse(readFileSync(configPath, "utf8"))).toEqual({
gateway: { mode: "local", reload: { mode: "off" } },
plugins: {
allow: ["discord"],
entries: { discord: { enabled: true } },
},
channels: { discord: { enabled: true } },
});
const restore = spawnSync(
process.execPath,
[SCRIPT_PATH, "restore-prepublish-auth-config", configPath, snapshotPath],
{ encoding: "utf8", env: { ...process.env } },
);
expect(restore.status, restore.stderr).toBe(0);
expect(readFileSync(configPath, "utf8")).toBe(authoredConfig);
});
it("rejects malformed probe config without changing authored bytes", () => {
const root = tempDirs.make("openclaw-clawhub-invalid-auth-config-");
const configPath = path.join(root, "openclaw.json");
const snapshotPath = path.join(root, "openclaw.authored.json");
const authoredConfig = '{"plugins":{"allow":"whatsapp"}}\n';
writeFileSync(configPath, authoredConfig);
const park = spawnSync(
process.execPath,
[SCRIPT_PATH, "park-prepublish-auth-config", configPath, snapshotPath],
{ encoding: "utf8", env: { ...process.env } },
);
expect(park.status).toBe(1);
expect(park.stderr).toContain("plugins.allow must be an array");
expect(readFileSync(configPath, "utf8")).toBe(authoredConfig);
expect(existsSync(snapshotPath)).toBe(false);
});
it("serves exact prepublish tarballs through the ClawHub artifact contract", async () => {
const root = tempDirs.make("openclaw-clawhub-prepublish-");
const isolatedCwd = tempDirs.make("openclaw-clawhub-isolated-");
+304 -29
View File
@@ -101,6 +101,7 @@ const RELEASE_USER_JOURNEY_SCENARIO_PATH = "scripts/e2e/lib/release-user-journey
const UPGRADE_SURVIVOR_RUN_SCRIPT = "scripts/e2e/lib/upgrade-survivor/run.sh";
const UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH =
"scripts/e2e/lib/upgrade-survivor/update-restart-auth.sh";
const UPGRADE_SURVIVOR_CONFIG_PARKING_PATH = "scripts/e2e/lib/upgrade-survivor/config-parking.mjs";
const GATEWAY_NETWORK_DOCKER_E2E_PATH = "scripts/e2e/gateway-network-docker.sh";
const BROWSER_CDP_SNAPSHOT_DOCKER_E2E_PATH = "scripts/e2e/browser-cdp-snapshot-docker.sh";
const SANDBOX_BROWSER_SIDECAR_DOCKER_E2E_PATH = "scripts/e2e/sandbox-browser-sidecar-docker.sh";
@@ -2525,9 +2526,13 @@ docker_e2e_docker_run_cmd run demo
const publishedRunner = readFileSync(UPGRADE_SURVIVOR_RUN_SCRIPT, "utf8");
const updateRestartAuth = readFileSync(UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH, "utf8");
expect(runner.indexOf("\nconfigure_plugin_registry\n")).toBeLessThan(
runner.indexOf('\necho "Running package update against the mounted tarball..."\n'),
const runnerPluginRegistryIndex = runner.indexOf("\nconfigure_plugin_registry\n");
const runnerCompanionInstallIndex = runner.indexOf("\ninstall_companion_plugins\n");
const runnerUpdateIndex = runner.indexOf(
'\necho "Running package update against the mounted tarball..."\n',
);
expect(runnerPluginRegistryIndex).toBeLessThan(runnerCompanionInstallIndex);
expect(runnerCompanionInstallIndex).toBeLessThan(runnerUpdateIndex);
expect(
publishedRunner.indexOf("phase configure-plugin-registry configure_plugin_registry"),
).toBeLessThan(publishedRunner.indexOf("phase update-candidate update_candidate"));
@@ -2535,13 +2540,11 @@ docker_e2e_docker_run_cmd run demo
const runnerPrepareIndex = runner.indexOf(
'prepare_update_restart_probe_current_install "$PORT" "$GATEWAY_LOG"',
);
const runnerPluginRegistryIndex = runner.indexOf("\nconfigure_plugin_registry\n");
expect(runnerClawHubIndex).toBeGreaterThan(-1);
expect(runnerClawHubIndex).toBeLessThan(runnerPrepareIndex);
expect(runnerPrepareIndex).toBeLessThan(runnerPluginRegistryIndex);
expect(runnerPluginRegistryIndex).toBeLessThan(
runner.indexOf('\necho "Running package update against the mounted tarball..."\n'),
);
expect(runnerClawHubIndex).toBeLessThan(runnerPluginRegistryIndex);
expect(runnerPluginRegistryIndex).toBeLessThan(runnerCompanionInstallIndex);
expect(runnerCompanionInstallIndex).toBeLessThan(runnerPrepareIndex);
expect(runnerPrepareIndex).toBeLessThan(runnerUpdateIndex);
const publishedClawHubIndex = publishedRunner.indexOf(
"phase configure-clawhub-fixture configure_clawhub_fixture",
);
@@ -2579,19 +2582,39 @@ docker_e2e_docker_run_cmd run demo
expect(publishedRunner.indexOf("phase assert-prepublish-requests node")).toBeLessThan(
publishedRunner.indexOf("phase doctor run_doctor"),
);
expect(runner.indexOf('openclaw "${update_args[@]}"')).toBeLessThan(
runner.indexOf(
'assert-prepublish-requests "$OPENCLAW_CLAWHUB_URL" "@openclaw/whatsapp" "$package_version"',
),
const discordInstallIndex = runner.indexOf(
'openclaw plugins install "npm:@openclaw/discord@$package_version" --pin --accept-capabilities',
);
expect(
runner.indexOf(
'assert-prepublish-requests "$OPENCLAW_CLAWHUB_URL" "@openclaw/whatsapp" "$package_version"',
),
).toBeLessThan(runner.indexOf("openclaw doctor --fix --non-interactive"));
expect(runner).toContain(
'if [ "${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-base}" = "feishu-channel" ]; then',
const whatsappInstallIndex = runner.indexOf(
'openclaw plugins install "clawhub:@openclaw/whatsapp@$package_version" --accept-capabilities',
);
const clawhubRequestIndex = runner.indexOf(
'assert-prepublish-requests "$OPENCLAW_CLAWHUB_URL" "@openclaw/whatsapp" "$package_version"',
);
const codexInstallIndex = runner.indexOf(
'openclaw plugins install "npm:@openclaw/codex@$package_version" --pin --accept-capabilities',
);
const restoreCompanionIndex = runner.indexOf(
'restore "$OPENCLAW_CONFIG_PATH" "$authored_config"',
);
const assertCompanionIndex = runner.indexOf('assert-companion-installs "$package_version"');
expect(discordInstallIndex).toBeGreaterThan(-1);
expect(discordInstallIndex).toBeLessThan(whatsappInstallIndex);
expect(whatsappInstallIndex).toBeLessThan(clawhubRequestIndex);
expect(clawhubRequestIndex).toBeLessThan(codexInstallIndex);
expect(codexInstallIndex).toBeLessThan(restoreCompanionIndex);
expect(restoreCompanionIndex).toBeLessThan(assertCompanionIndex);
expect(assertCompanionIndex).toBeLessThan(runnerPrepareIndex);
expect(runner).toContain('park-companion-install "$OPENCLAW_CONFIG_PATH" "$authored_config"');
expectTextToIncludeAll(runner, [
"install_status=$?",
"restore_status=$?",
'if [ "$install_status" -ne 0 ]; then',
'return "$install_status"',
'if [ "$restore_status" -ne 0 ]; then',
'return "$restore_status"',
]);
expect(runner).toContain('if [ "$SCENARIO" = "feishu-channel" ]; then');
expect(publishedRunner).toContain('if [ "$SCENARIO" = "feishu-channel" ]; then');
expect(publishedRunner).toContain(
[
@@ -2601,6 +2624,13 @@ docker_e2e_docker_run_cmd run demo
"fi",
].join("\n"),
);
expect(runner).toContain(
[
'if [ "$SCENARIO" = "configured-plugin-installs" ] || [ "$SCENARIO" = "sqlite-volume" ]; then',
' export BRAVE_API_KEY="BSA_upgrade_survivor_brave_key"',
"fi",
].join("\n"),
);
for (const script of [runner, publishedRunner]) {
expectTextToIncludeAll(script, [
"prepublish-artifacts",
@@ -2622,12 +2652,11 @@ docker_e2e_docker_run_cmd run demo
}
expectTextToIncludeAll(publishedRunner, [
"park_prepublish_authored_config",
"park-prepublish-auth-config",
"park-prepublish",
"assert_prepublish_fixture_idle",
"assert-no-requests",
"restore_prepublish_authored_config",
"restore-prepublish-auth-config",
"cmp -s",
"config-parking.mjs",
"'^(GATEWAY_AUTH_TOKEN_REF|OPENCLAW_CLAWHUB_URL)='",
"OPENCLAW_CLAWHUB_URL=%s",
]);
@@ -2641,15 +2670,22 @@ docker_e2e_docker_run_cmd run demo
publishedRunner.lastIndexOf("write_update_restart_service_env"),
);
for (const script of [runner, updateRestartAuth]) {
expect(script).not.toContain("park-prepublish-auth-config");
expect(script).not.toContain("assert-no-requests");
}
expect(updateRestartAuth).toContain("park-restart-probe");
expect(updateRestartAuth).toContain('"$OPENCLAW_CONFIG_PATH"');
expect(publishedRunner).not.toContain(
'\nexport MATRIX_ACCESS_TOKEN="upgrade-survivor-matrix-token"\n',
);
expect(publishedRunner).not.toContain(
'\nexport BRAVE_API_KEY="BSA_upgrade_survivor_brave_key"\n',
);
expect(runner).not.toContain('\nexport BRAVE_API_KEY="BSA_upgrade_survivor_brave_key"\n');
expect(runner).toContain(
'source "$HARNESS_ROOT_DIR/scripts/e2e/lib/prepublish-plugin-registry.sh"',
);
expect(runner).toContain("openclaw_prepublish_plugin_registry_configure_docker_args");
expect(runner).not.toContain("configure_prepublish_plugin_registry()");
expect(
runner.match(
/-v "\$HARNESS_ROOT_DIR\/scripts\/e2e\/lib\/clawhub-fixture-server\.cjs:\/tmp\/openclaw-clawhub-fixture-server\.cjs:ro"/gu,
@@ -2660,6 +2696,16 @@ docker_e2e_docker_run_cmd run demo
/-e OPENCLAW_UPGRADE_SURVIVOR_CLAWHUB_FIXTURE_SERVER=\/tmp\/openclaw-clawhub-fixture-server\.cjs/gu,
),
).toHaveLength(2);
expect(
runner.match(
/-v "\$HARNESS_ROOT_DIR\/scripts\/e2e\/lib\/upgrade-survivor\/config-parking\.mjs:\/tmp\/openclaw-config-parking\.mjs:ro"/gu,
),
).toHaveLength(2);
expect(
runner.match(
/-e OPENCLAW_UPGRADE_SURVIVOR_CONFIG_PARKING_HELPER=\/tmp\/openclaw-config-parking\.mjs/gu,
),
).toHaveLength(2);
});
it("keeps upgrade survivor wrappers and the embedded payload valid bash", () => {
@@ -3123,6 +3169,200 @@ fi
);
});
it("scopes candidate device identity doctor markers to the doctor process", () => {
const workDir = tempDirs.make("openclaw-upgrade-survivor-doctor-env-");
writeExecutables(join(workDir, "bin"), {
openclaw: `#!/usr/bin/env bash
set -euo pipefail
printf '%s\\n' "$@" >"$CAPTURE_DIR/doctor-argv"
{
printf 'OPENCLAW_UPDATE_IN_PROGRESS=%s\\n' "\${OPENCLAW_UPDATE_IN_PROGRESS-unset}"
printf 'OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR=%s\\n' "\${OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR-unset}"
printf 'OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE=%s\\n' "\${OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE-unset}"
} >"$CAPTURE_DIR/doctor-env"
exit 23
`,
});
const script = repoShell(workDir)`
export PATH="$TMPDIR/bin:$PATH"
export CAPTURE_DIR="$TMPDIR"
export OPENCLAW_CONFIG_PATH="$TMPDIR/openclaw.json"
export OPENCLAW_UPGRADE_SURVIVOR_CONFIG_PARKING_HELPER="$ROOT_DIR/${UPGRADE_SURVIVOR_CONFIG_PARKING_PATH}"
printf '%s\n' '{"gateway":{"mode":"local"}}' >"$OPENCLAW_CONFIG_PATH"
unset OPENCLAW_UPDATE_IN_PROGRESS
unset OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR
unset OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE
source "$ROOT_DIR/${UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH}"
install_update_restart_systemctl_shim() { :; }
seed_update_restart_probe_device_auth() { :; }
openclaw_e2e_maybe_timeout() {
shift
"$@"
}
if prepare_update_restart_probe_current_install 18789 "$TMPDIR/gateway.log" >/dev/null 2>&1; then
echo "doctor unexpectedly succeeded" >&2
exit 3
fi
{
printf 'OPENCLAW_UPDATE_IN_PROGRESS=%s\\n' "\${OPENCLAW_UPDATE_IN_PROGRESS-unset}"
printf 'OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR=%s\\n' "\${OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR-unset}"
printf 'OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE=%s\\n' "\${OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE-unset}"
} >"$CAPTURE_DIR/parent-env"
`;
const result = spawnSync("bash", ["-lc", script], { encoding: "utf8" });
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
expect(readFileSync(join(workDir, "doctor-argv"), "utf8").trimEnd().split("\n")).toEqual([
"doctor",
"--fix",
"--non-interactive",
]);
expect(readFileSync(join(workDir, "doctor-env"), "utf8")).toBe(
[
"OPENCLAW_UPDATE_IN_PROGRESS=1",
"OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR=1",
"OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE=1",
"",
].join("\n"),
);
expect(readFileSync(join(workDir, "parent-env"), "utf8")).toBe(
[
"OPENCLAW_UPDATE_IN_PROGRESS=unset",
"OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR=unset",
"OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE=unset",
"",
].join("\n"),
);
});
it.each([
["doctor", 41],
["readiness", 42],
["service-env", 43],
["install", 44],
] as const)(
"restores the canonical authored config after %s failure",
(failureStage, expectedStatus) => {
const workDir = tempDirs.make(`openclaw-upgrade-survivor-${failureStage}-failure-`);
writeExecutables(join(workDir, "bin"), {
openclaw: `#!/usr/bin/env bash
set -euo pipefail
printf '%s %s\n' "$OPENCLAW_CONFIG_PATH" "$*" >>"$CAPTURE_DIR/openclaw-calls"
if [ "$FAILURE_STAGE" = doctor ] && [ "\${1:-}" = doctor ]; then
exit 41
fi
if [ "\${1:-}" = gateway ] && [ "\${2:-}" = install ]; then
[ "$FAILURE_STAGE" != install ] || exit 44
exit 0
fi
sleep 30
`,
});
const script = repoShell(workDir)`
export PATH="$TMPDIR/bin:$PATH"
export CAPTURE_DIR="$TMPDIR"
export FAILURE_STAGE="${failureStage}"
export OPENCLAW_STATE_DIR="$TMPDIR/state"
export OPENCLAW_CONFIG_PATH="$OPENCLAW_STATE_DIR/openclaw.json"
export OPENCLAW_UPGRADE_SURVIVOR_CONFIG_PARKING_HELPER="$ROOT_DIR/${UPGRADE_SURVIVOR_CONFIG_PARKING_PATH}"
export OPENCLAW_UPGRADE_SURVIVOR_SYSTEMCTL_SHIM_PID_FILE="$TMPDIR/gateway.pid"
export OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SERVICE_INSTALL_JSON="$TMPDIR/install.json"
export OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SERVICE_INSTALL_ERR="$TMPDIR/install.err"
export GATEWAY_AUTH_TOKEN_REF=upgrade-survivor-token
mkdir -p "$OPENCLAW_STATE_DIR"
authored_config='{"channels":{"discord":{"dm":{"policy":"allowlist","allowFrom":["123"]}}}}'
printf '%s\n' "$authored_config" >"$OPENCLAW_CONFIG_PATH"
source "$ROOT_DIR/${UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH}"
install_update_restart_systemctl_shim() { :; }
seed_update_restart_probe_device_auth() { :; }
openclaw_e2e_maybe_timeout() {
shift
"$@"
}
openclaw_e2e_wait_gateway_ready() {
[ "$FAILURE_STAGE" != readiness ] || return 42
}
write_update_restart_service_auth_env() {
[ "$FAILURE_STAGE" != service-env ] || return 43
}
status=0
prepare_update_restart_probe_current_install 18789 "$TMPDIR/gateway.log" >/dev/null 2>&1 || status=$?
printf '%s\n' "$status" >"$CAPTURE_DIR/status"
cmp -s "$OPENCLAW_CONFIG_PATH" <(printf '%s\n' "$authored_config")
[ ! -e "$TMPDIR/gateway.log.authored-config" ]
if [ -n "\${gateway_pid:-}" ]; then
kill "$gateway_pid" >/dev/null 2>&1 || true
wait "$gateway_pid" >/dev/null 2>&1 || true
fi
`;
const result = spawnSync("bash", ["-lc", script], { encoding: "utf8" });
expect(result.status, result.stderr).toBe(0);
expect(readFileSync(join(workDir, "status"), "utf8")).toBe(`${expectedStatus}\n`);
const calls = readFileSync(join(workDir, "openclaw-calls"), "utf8");
expect(calls).toContain(join(workDir, "state", "openclaw.json"));
expect(calls).not.toContain("OPENCLAW_CONFIG_PATH=");
},
);
it("prefers restore failure and retains the authored config snapshot", () => {
const workDir = tempDirs.make("openclaw-upgrade-survivor-restore-failure-");
writeExecutables(join(workDir, "bin"), {
openclaw: `#!/usr/bin/env bash
set -euo pipefail
exit 41
`,
"config-parking-wrapper.mjs": `import { spawnSync } from "node:child_process";
const args = process.argv.slice(2);
if (args[0] === "restore") {
process.exit(57);
}
const result = spawnSync(
process.execPath,
[process.env.REAL_CONFIG_PARKING_HELPER, ...args],
{ stdio: "inherit", env: process.env },
);
process.exit(result.status ?? 1);
`,
});
const script = repoShell(workDir)`
export PATH="$TMPDIR/bin:$PATH"
export OPENCLAW_STATE_DIR="$TMPDIR/state"
export OPENCLAW_CONFIG_PATH="$OPENCLAW_STATE_DIR/openclaw.json"
export REAL_CONFIG_PARKING_HELPER="$ROOT_DIR/${UPGRADE_SURVIVOR_CONFIG_PARKING_PATH}"
export OPENCLAW_UPGRADE_SURVIVOR_CONFIG_PARKING_HELPER="$TMPDIR/bin/config-parking-wrapper.mjs"
mkdir -p "$OPENCLAW_STATE_DIR"
printf '%s\n' '{"channels":{"discord":{"dm":{"policy":"allowlist"}}}}' >"$OPENCLAW_CONFIG_PATH"
source "$ROOT_DIR/${UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH}"
install_update_restart_systemctl_shim() { :; }
seed_update_restart_probe_device_auth() { :; }
openclaw_e2e_maybe_timeout() {
shift
"$@"
}
status=0
prepare_update_restart_probe_current_install 18789 "$TMPDIR/gateway.log" >/dev/null 2>&1 || status=$?
printf '%s\n' "$status" >"$TMPDIR/status"
`;
const result = spawnSync("bash", ["-lc", script], { encoding: "utf8" });
expect(result.status, result.stderr).toBe(0);
expect(readFileSync(join(workDir, "status"), "utf8")).toBe("57\n");
expect(existsSync(join(workDir, "gateway.log.authored-config"))).toBe(true);
expect(JSON.parse(readFileSync(join(workDir, "state", "openclaw.json"), "utf8"))).toEqual({
plugins: { enabled: false },
gateway: expect.objectContaining({ reload: { mode: "off" } }),
});
});
it("keeps upgrade survivor auto-auth success summary set -u safe", () => {
const runner = readFileSync(UPGRADE_SURVIVOR_DOCKER_E2E_PATH, "utf8");
const summaryDefaultIndex = runner.indexOf('startup_summary="n/a"');
@@ -5179,8 +5419,16 @@ done
});
it("uses the account home for upgrade survivor auto-auth state", () => {
const runner = readFileSync(UPGRADE_SURVIVOR_RUN_SCRIPT, "utf8");
expectTextToIncludeAll(runner, [
const publishedRunner = readFileSync(UPGRADE_SURVIVOR_RUN_SCRIPT, "utf8");
const wrapper = readFileSync(UPGRADE_SURVIVOR_DOCKER_E2E_PATH, "utf8");
const directRunner = extractUpgradeSurvivorPayload(wrapper);
expectTextToIncludeAll(wrapper, [
'OPENCLAW_TEST_STATE_FUNCTION_B64="$(docker_e2e_test_state_function_b64)"',
'-e OPENCLAW_TEST_STATE_FUNCTION_B64="$OPENCLAW_TEST_STATE_FUNCTION_B64"',
]);
expect(wrapper).not.toContain("OPENCLAW_TEST_STATE_SCRIPT_B64");
expectTextToIncludeAll(publishedRunner, [
'if [ "$UPDATE_RESTART_MODE" = "auto-auth" ]; then',
'account_home="$(getent passwd "$(id -u)" | cut -d: -f6)"',
'if [ -z "$account_home" ]; then',
@@ -5191,12 +5439,39 @@ done
'export OPENCLAW_CONFIG_PATH="$OPENCLAW_STATE_DIR/openclaw.json"',
]);
expect(runner.indexOf("unset OPENCLAW_HOME")).toBeLessThan(
runner.indexOf('export OPENCLAW_STATE_DIR="$account_home/.openclaw"'),
expect(publishedRunner.indexOf("unset OPENCLAW_HOME")).toBeLessThan(
publishedRunner.indexOf('export OPENCLAW_STATE_DIR="$account_home/.openclaw"'),
);
expect(
runner.indexOf('export OPENCLAW_CONFIG_PATH="$OPENCLAW_STATE_DIR/openclaw.json"'),
).toBeLessThan(runner.indexOf("node scripts/e2e/lib/upgrade-survivor/assertions.mjs seed"));
publishedRunner.indexOf('export OPENCLAW_CONFIG_PATH="$OPENCLAW_STATE_DIR/openclaw.json"'),
).toBeLessThan(
publishedRunner.indexOf("node scripts/e2e/lib/upgrade-survivor/assertions.mjs seed"),
);
expectTextToIncludeAll(directRunner, [
'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)"',
'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",
]);
expect(directRunner.indexOf('openclaw_test_state_create "$account_home"')).toBeLessThan(
directRunner.indexOf("unset OPENCLAW_HOME"),
);
expect(directRunner.indexOf("unset OPENCLAW_HOME")).toBeLessThan(
directRunner.indexOf("prepare_update_restart_probe_current_install"),
);
expect(
directRunner.indexOf('export OPENCLAW_CONFIG_PATH="$OPENCLAW_STATE_DIR/openclaw.json"'),
).toBeLessThan(
directRunner.indexOf("node scripts/e2e/lib/upgrade-survivor/assertions.mjs seed"),
);
});
it("bounds doctor install switch command log diagnostics", () => {
@@ -0,0 +1,75 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { LaneState } from "../../scripts/lib/cross-os-release-checks/config.ts";
const mocks = vi.hoisted(() => ({
runInstalledCli: vi.fn().mockResolvedValue(undefined),
runOpenClaw: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../../scripts/lib/cross-os-release-checks/installed.ts", async (importOriginal) => ({
...(await importOriginal<
typeof import("../../scripts/lib/cross-os-release-checks/installed.ts")
>()),
runInstalledCli: mocks.runInstalledCli,
}));
vi.mock("../../scripts/lib/cross-os-release-checks/runtime.ts", async (importOriginal) => ({
...(await importOriginal<
typeof import("../../scripts/lib/cross-os-release-checks/runtime.ts")
>()),
runOpenClaw: mocks.runOpenClaw,
}));
import { installLaneCompanions } from "../../scripts/lib/cross-os-release-checks/lane-companions.ts";
function createLane(): LaneState {
return {
name: "fresh",
rootDir: "/tmp/openclaw-release",
prefixDir: "/tmp/openclaw-release/prefix",
homeDir: "/tmp/openclaw-release/home",
stateDir: "/tmp/openclaw-release/state",
appDataDir: "/tmp/openclaw-release/app-data",
gatewayPort: 18789,
phaseTimings: [],
};
}
describe("cross-OS release companion installation", () => {
afterEach(() => {
mocks.runInstalledCli.mockClear();
mocks.runOpenClaw.mockClear();
});
it.each([
{ cliPath: undefined, runner: "packaged" },
{ cliPath: "/tmp/openclaw", runner: "installed" },
] as const)("accepts declared capabilities through the $runner runner", async ({ cliPath }) => {
const lane = createLane();
const env = { HOME: lane.homeDir };
await installLaneCompanions({
companions: [{ name: "@openclaw/codex", tarballPath: "/tmp/openclaw-codex.tgz" }],
logsDir: "/tmp/openclaw-release/logs",
lane,
env,
...(cliPath ? { cliPath } : {}),
});
const expectedArgs = [
"plugins",
"install",
"npm-pack:/tmp/openclaw-codex.tgz",
"--force",
"--accept-capabilities",
];
const expectedCall = expect.objectContaining({ args: expectedArgs, env });
if (cliPath) {
expect(mocks.runInstalledCli).toHaveBeenCalledWith(expectedCall);
expect(mocks.runOpenClaw).not.toHaveBeenCalled();
} else {
expect(mocks.runOpenClaw).toHaveBeenCalledWith(expectedCall);
expect(mocks.runInstalledCli).not.toHaveBeenCalled();
}
});
});
@@ -1,5 +1,6 @@
// Upgrade Survivor Assertions tests cover upgrade survivor assertions script behavior.
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -268,6 +269,7 @@ function assertConfig(params: {
acceptedIntents: string[];
config: unknown;
scenario: string;
stage?: "baseline" | "survival";
}): void {
const root = mkdtempSync(join(tmpdir(), "openclaw-upgrade-survivor-config-"));
try {
@@ -285,6 +287,120 @@ function assertConfig(params: {
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_UPGRADE_SURVIVOR_CONFIG_COVERAGE_JSON: coveragePath,
OPENCLAW_UPGRADE_SURVIVOR_SCENARIO: params.scenario,
OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE: params.stage ?? "survival",
},
stdio: "pipe",
});
} finally {
rmSync(root, { force: true, recursive: true });
}
}
const ACCEPTED_SURFACE = {
channels: [],
providers: [],
tools: [],
contracts: [],
hooks: [],
mcpServers: [],
cliCommands: [],
cliBackends: [],
skills: [],
dangerousConfigFlags: [],
};
function acceptedSurfaceHash(): string {
return createHash("sha256").update(JSON.stringify(ACCEPTED_SURFACE)).digest("hex");
}
function assertCompanionPluginRecords(
mutate?: (
records: Record<string, Record<string, unknown>>,
installPaths: Record<"codex" | "discord" | "whatsapp", string>,
) => void,
): void {
const root = mkdtempSync(join(tmpdir(), "openclaw-upgrade-survivor-companions-"));
try {
const stateDir = join(root, "state");
const version = "2026.8.1";
const discordInstallPath = join(
stateDir,
"npm",
"projects",
"discord",
"node_modules",
"@openclaw",
"discord",
);
const codexInstallPath = join(
stateDir,
"npm",
"projects",
"codex",
"node_modules",
"@openclaw",
"codex",
);
const whatsappInstallPath = join(stateDir, "extensions", "whatsapp");
for (const [installPath, packageName] of [
[discordInstallPath, "@openclaw/discord"],
[whatsappInstallPath, "@openclaw/whatsapp"],
[codexInstallPath, "@openclaw/codex"],
] as const) {
mkdirSync(installPath, { recursive: true });
writeJson(join(installPath, "package.json"), { name: packageName, version });
}
const npmIntegrity = "sha512-upgrade-survivor";
const clawpackSha256 = "a".repeat(64);
const consent = (integrity: string) => ({
acceptedSurface: ACCEPTED_SURFACE,
acceptedSurfaceHash: acceptedSurfaceHash(),
acceptedSurfaceAt: "2026-08-27T00:00:00.000Z",
acceptedSurfaceIntegrity: integrity,
});
const records: Record<string, Record<string, unknown>> = {
discord: {
source: "npm",
spec: `@openclaw/discord@${version}`,
resolvedName: "@openclaw/discord",
resolvedVersion: version,
integrity: npmIntegrity,
installPath: discordInstallPath,
...consent(npmIntegrity),
},
whatsapp: {
source: "clawhub",
spec: `clawhub:@openclaw/whatsapp@${version}`,
version,
clawhubPackage: "@openclaw/whatsapp",
clawhubChannel: "official",
artifactKind: "npm-pack",
clawpackSha256,
installPath: whatsappInstallPath,
...consent(clawpackSha256),
},
codex: {
source: "npm",
spec: `@openclaw/codex@${version}`,
resolvedName: "@openclaw/codex",
resolvedVersion: version,
integrity: npmIntegrity,
installPath: codexInstallPath,
...consent(npmIntegrity),
},
};
mutate?.(records, {
codex: codexInstallPath,
discord: discordInstallPath,
whatsapp: whatsappInstallPath,
});
mkdirSync(join(stateDir, "plugins"), { recursive: true });
writeJson(join(stateDir, "plugins", "installs.json"), { installRecords: records });
execFileSync(process.execPath, [ASSERTIONS_PATH, "assert-companion-installs", version], {
env: {
...process.env,
OPENCLAW_STATE_DIR: stateDir,
},
stdio: "pipe",
});
@@ -527,6 +643,112 @@ describe("upgrade survivor assertions", () => {
).not.toThrow();
});
it("allows legacy Discord DM config only at the baseline stage", () => {
const legacyConfig = {
channels: {
discord: {
enabled: true,
dm: { policy: "allowlist", allowFrom: ["111111111111111111"] },
guilds: {
"222222222222222222": {
channels: { "333333333333333333": { requireMention: true } },
},
},
threadBindings: { idleHours: 72 },
},
},
};
expect(() =>
assertConfig({
acceptedIntents: ["discord-channel"],
config: legacyConfig,
scenario: "base",
stage: "baseline",
}),
).not.toThrow();
expect(() =>
assertConfig({
acceptedIntents: ["discord-channel"],
config: legacyConfig,
scenario: "base",
}),
).toThrow(/legacy Discord DM config survived/);
});
it("requires canonical Discord DM config after update", () => {
expect(() =>
assertConfig({
acceptedIntents: ["discord-channel"],
config: {
channels: {
discord: {
enabled: true,
dmPolicy: "allowlist",
allowFrom: ["111111111111111111"],
guilds: {
"222222222222222222": {
channels: { "333333333333333333": { requireMention: true } },
},
},
threadBindings: { idleHours: 72 },
},
},
},
scenario: "base",
}),
).not.toThrow();
});
it("requires exact artifact-bound consent for direct companion installs", () => {
expect(() => assertCompanionPluginRecords()).not.toThrow();
expect(() =>
assertCompanionPluginRecords((records) => {
const discord = records.discord;
if (!discord) {
throw new Error("discord fixture missing");
}
Reflect.deleteProperty(discord, "acceptedSurfaceIntegrity");
}),
).toThrow(/discord plugin consent integrity/);
});
it.each([
["npm", "discord", "resolvedVersion", "version"],
["ClawHub", "whatsapp", "version", "resolvedVersion"],
] as const)(
"requires the source-native version field for %s companion installs",
(_sourceLabel, pluginId, requiredField, alternateField) => {
expect(() =>
assertCompanionPluginRecords((records) => {
const record = records[pluginId];
if (!record) {
throw new Error(`${pluginId} fixture missing`);
}
record[alternateField] = record[requiredField];
Reflect.deleteProperty(record, requiredField);
}),
).toThrow(new RegExp(`${pluginId} plugin version changed`));
},
);
it.each([
["npm", "discord"],
["ClawHub", "whatsapp"],
] as const)(
"requires the installed package version to match for %s companion installs",
(_sourceLabel, pluginId) => {
expect(() =>
assertCompanionPluginRecords((_records, installPaths) => {
const packageName = pluginId === "discord" ? "@openclaw/discord" : "@openclaw/whatsapp";
writeJson(join(installPaths[pluginId], "package.json"), {
name: packageName,
version: "2026.8.0",
});
}),
).toThrow(new RegExp(`${pluginId} installed package version changed`));
},
);
it("accepts official ClawHub npm-pack installs for configured external plugins", () => {
expect(() => assertConfiguredPluginState()).not.toThrow();
});
@@ -0,0 +1,193 @@
import { spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const SCRIPT_PATH = path.resolve("scripts/e2e/lib/upgrade-survivor/config-parking.mjs");
const SURVIVOR_SCRIPT_PATH = path.resolve("scripts/e2e/upgrade-survivor-docker.sh");
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
function run(...args: string[]) {
return spawnSync(process.execPath, [SCRIPT_PATH, ...args], {
encoding: "utf8",
env: { ...process.env },
});
}
describe("upgrade survivor config parking", () => {
it("preserves published prepublish parking behavior and restores exact bytes", () => {
const root = tempDirs.make("openclaw-prepublish-config-parking-");
const configPath = path.join(root, "openclaw.json");
const snapshotPath = path.join(root, "openclaw.authored.json");
const authoredConfig = `{
"gateway": { "mode": "local", "reload": { "mode": "hybrid" } },
"plugins": {
"allow": ["discord", "whatsapp"],
"entries": { "discord": { "enabled": true }, "whatsapp": { "enabled": true } }
},
"channels": { "discord": { "enabled": true }, "whatsapp": { "enabled": true } }
}
`;
writeFileSync(configPath, authoredConfig);
const park = run("park-prepublish", configPath, snapshotPath);
expect(park.status, park.stderr).toBe(0);
expect(readFileSync(snapshotPath, "utf8")).toBe(authoredConfig);
expect(JSON.parse(readFileSync(configPath, "utf8"))).toEqual({
gateway: { mode: "local", reload: { mode: "off" } },
plugins: {
allow: ["discord"],
entries: { discord: { enabled: true } },
},
channels: { discord: { enabled: true } },
});
const restore = run("restore", configPath, snapshotPath);
expect(restore.status, restore.stderr).toBe(0);
expect(readFileSync(configPath, "utf8")).toBe(authoredConfig);
expect(existsSync(snapshotPath)).toBe(false);
});
it("parks legacy authored config behind a strict restart probe config", () => {
const root = tempDirs.make("openclaw-restart-config-parking-");
const configPath = path.join(root, "openclaw.json");
const snapshotPath = path.join(root, "openclaw.authored.json");
const authoredConfig =
'{"channels":{"discord":{"dm":{"policy":"allowlist","allowFrom":["123"]}}}}\n';
writeFileSync(configPath, authoredConfig);
const park = run("park-restart-probe", configPath, snapshotPath, "19876");
expect(park.status, park.stderr).toBe(0);
expect(readFileSync(snapshotPath, "utf8")).toBe(authoredConfig);
expect(JSON.parse(readFileSync(configPath, "utf8"))).toEqual({
plugins: { enabled: false },
gateway: {
port: 19876,
mode: "local",
bind: "loopback",
controlUi: { enabled: false },
auth: {
mode: "token",
token: {
source: "env",
provider: "default",
id: "GATEWAY_AUTH_TOKEN_REF",
},
},
reload: { mode: "off" },
},
});
});
it("parks companion installs behind a plugin-disabled config and restores exact bytes", () => {
const root = tempDirs.make("openclaw-companion-config-parking-");
const configPath = path.join(root, "openclaw.json");
const snapshotPath = path.join(root, "openclaw.authored.json");
const authoredConfig =
'{"channels":{"discord":{"dm":{"policy":"allowlist","allowFrom":["123"]}}}}\n';
writeFileSync(configPath, authoredConfig);
const park = run("park-companion-install", configPath, snapshotPath);
expect(park.status, park.stderr).toBe(0);
expect(readFileSync(snapshotPath, "utf8")).toBe(authoredConfig);
expect(JSON.parse(readFileSync(configPath, "utf8"))).toEqual({
plugins: { enabled: false },
});
writeFileSync(configPath, '{"plugins":{"allow":["discord"]}}\n');
const restore = run("restore", configPath, snapshotPath);
expect(restore.status, restore.stderr).toBe(0);
expect(readFileSync(configPath, "utf8")).toBe(authoredConfig);
expect(existsSync(snapshotPath)).toBe(false);
});
it("restores authored bytes and preserves the failing companion install status", () => {
const root = tempDirs.make("openclaw-companion-install-failure-");
const binDir = path.join(root, "bin");
const configPath = path.join(root, "openclaw.json");
const invocationPath = path.join(root, "openclaw-invocations");
const runnerPath = path.join(root, "run-companion-install.sh");
const authoredConfig =
'{"channels":{"discord":{"dm":{"policy":"allowlist","allowFrom":["123"]}}}}\n';
mkdirSync(binDir);
writeFileSync(configPath, authoredConfig);
const survivorScript = readFileSync(SURVIVOR_SCRIPT_PATH, "utf8");
const functionStart = survivorScript.indexOf("install_companion_plugins() {");
const functionEnd = survivorScript.indexOf(
"\n}\n\nopenclaw_e2e_eval_test_state_from_b64",
functionStart,
);
expect(functionStart).toBeGreaterThan(-1);
expect(functionEnd).toBeGreaterThan(functionStart);
const functionSource = survivorScript.slice(functionStart, functionEnd + 2);
writeFileSync(
path.join(binDir, "openclaw"),
`#!/usr/bin/env bash
set -euo pipefail
count=0
if [ -f "$OPENCLAW_INVOCATION_PATH" ]; then
count="$(cat "$OPENCLAW_INVOCATION_PATH")"
fi
count=$((count + 1))
printf '%s' "$count" >"$OPENCLAW_INVOCATION_PATH"
if [ "$count" -eq 2 ]; then
exit 23
fi
`,
);
chmodSync(path.join(binDir, "openclaw"), 0o755);
writeFileSync(
runnerPath,
`#!/usr/bin/env bash
set -euo pipefail
${functionSource}
install_companion_plugins
`,
);
const result = spawnSync("bash", [runnerPath], {
encoding: "utf8",
env: {
...process.env,
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_INVOCATION_PATH: invocationPath,
OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT: root,
OPENCLAW_UPGRADE_SURVIVOR_CONFIG_PARKING_HELPER: SCRIPT_PATH,
OPENCLAW_UPGRADE_SURVIVOR_CLAWHUB_FIXTURE_SERVER: "unused",
PATH: `${binDir}:${process.env.PATH ?? ""}`,
package_version: "2026.8.1",
},
});
expect(result.status, result.stderr).toBe(23);
expect(readFileSync(configPath, "utf8")).toBe(authoredConfig);
expect(existsSync(path.join(root, "companion-install-authored.json"))).toBe(false);
});
it("rejects malformed config without changing authored bytes", () => {
const root = tempDirs.make("openclaw-invalid-config-parking-");
const configPath = path.join(root, "openclaw.json");
const snapshotPath = path.join(root, "openclaw.authored.json");
const authoredConfig = '{"plugins":{"allow":"whatsapp"}}\n';
writeFileSync(configPath, authoredConfig);
const park = run("park-prepublish", configPath, snapshotPath);
expect(park.status).toBe(1);
expect(park.stderr).toContain("plugins.allow must be an array");
expect(readFileSync(configPath, "utf8")).toBe(authoredConfig);
expect(existsSync(snapshotPath)).toBe(false);
});
it("keeps the snapshot when restore cannot replace the config path", () => {
const root = tempDirs.make("openclaw-failed-config-restore-");
const configPath = path.join(root, "config-directory");
const snapshotPath = path.join(root, "openclaw.authored.json");
mkdirSync(configPath);
writeFileSync(snapshotPath, '{"gateway":{"mode":"local"}}\n');
const restore = run("restore", configPath, snapshotPath);
expect(restore.status).toBe(1);
expect(existsSync(snapshotPath)).toBe(true);
});
});
@@ -43,7 +43,7 @@ fi
printf '%s\n' "$*" >>"$CAPTURE_DIR/node-args"
printf '%s|%s|%s\n' \
"$OPENCLAW_DOCKER_ALL_LANES" \
"$OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS" \
"\${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS:-}" \
"$OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS" >>"$CAPTURE_DIR/node-env"
mkdir -p "$OPENCLAW_DOCKER_ALL_LOG_DIR/prepublish-plugin-registry"
printf '%s' "$REGISTRY_MANIFEST" \
@@ -92,6 +92,36 @@ done
}
describe("standalone upgrade survivor plugin registry", () => {
it("prepares and mounts the direct auto-auth planner registry", () => {
const { captureDir, result } = runSurvivor({
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: undefined,
OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE: "0",
OPENCLAW_UPGRADE_SURVIVOR_UPDATE_RESTART_MODE: "auto-auth",
});
expect(result.status, result.stderr).toBe(0);
expect(readFileSync(join(captureDir, "node-env"), "utf8")).toBe("update-restart-auth||base\n");
});
it("preserves an explicitly supplied direct registry", () => {
const registryDir = tempDirs.make("openclaw-direct-plugin-registry-");
const manifestPath = join(registryDir, "prepublish-plugin-registry.json");
writeFileSync(manifestPath, registryManifest());
const { captureDir, result } = runSurvivor({
OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR: registryDir,
OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_MANIFEST_SHA256: createHash("sha256")
.update(readFileSync(manifestPath))
.digest("hex"),
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: undefined,
OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE: "0",
OPENCLAW_UPGRADE_SURVIVOR_UPDATE_RESTART_MODE: "auto-auth",
});
expect(result.status, result.stderr).toBe(0);
expect(existsSync(join(captureDir, "node-args"))).toBe(false);
});
it("prepares and mounts a planner-owned registry for the current candidate", () => {
const { captureDir, result } = runSurvivor({
OPENCLAW_UPGRADE_SURVIVOR_SCENARIO: "configured-plugin-installs",