mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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-");
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user