Tolerate corrupt plugins during update (#77706)

* fix(update): tolerate corrupt plugin state

* fix(update): preserve corrupt plugin proof state

* fix(update): narrow corrupt plugin warnings

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Patrick Erichsen
2026-05-05 14:18:26 -07:00
committed by GitHub
parent d94e7f5114
commit 8aa7b7a4ca
19 changed files with 504 additions and 107 deletions

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env bash
set -euo pipefail
source scripts/lib/openclaw-e2e-instance.sh
source scripts/e2e/lib/plugins/fixtures.sh
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
export npm_config_loglevel=error
export npm_config_fund=false
export npm_config_audit=false
export npm_config_prefix=/tmp/npm-prefix
export NPM_CONFIG_PREFIX=/tmp/npm-prefix
export PATH="/tmp/npm-prefix/bin:$PATH"
export CI=true
export OPENCLAW_DISABLE_BUNDLED_PLUGINS=1
export OPENCLAW_NO_ONBOARD=1
export OPENCLAW_NO_PROMPT=1
baseline="${OPENCLAW_UPDATE_CORRUPT_PLUGIN_BASELINE:-openclaw@latest}"
echo "Installing baseline OpenClaw package: $baseline"
if ! npm install -g --prefix /tmp/npm-prefix --omit=optional "$baseline" >/tmp/openclaw-update-corrupt-baseline-install.log 2>&1; then
cat /tmp/openclaw-update-corrupt-baseline-install.log >&2 || true
exit 1
fi
package_root="$(openclaw_e2e_package_root /tmp/npm-prefix)"
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
export OPENCLAW_ENTRY="$entry"
npm_pack_dir="$(mktemp -d "/tmp/openclaw-corrupt-plugin-pack.XXXXXX")"
npm_registry_dir="$(mktemp -d "/tmp/openclaw-corrupt-plugin-registry.XXXXXX")"
pack_fixture_plugin "$npm_pack_dir" /tmp/demo-corrupt-plugin.tgz demo-corrupt-plugin 0.0.1 demo.corrupt "Demo Corrupt Plugin"
start_npm_fixture_registry "@openclaw/demo-corrupt-plugin" "0.0.1" /tmp/demo-corrupt-plugin.tgz "$npm_registry_dir"
echo "Installing managed external plugin..."
node "$entry" plugins install "npm:@openclaw/demo-corrupt-plugin@0.0.1" >/tmp/openclaw-corrupt-plugin-install.log 2>&1
node "$entry" plugins inspect demo-corrupt-plugin --runtime --json >/tmp/openclaw-corrupt-plugin-before.json
unset NPM_CONFIG_REGISTRY npm_config_registry
plugin_dir="$(
node -e '
const fs = require("node:fs");
const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const installPath = payload.install?.installPath ?? payload.plugin?.rootDir;
if (!installPath) {
throw new Error("missing plugin install path in inspect output");
}
process.stdout.write(installPath);
' /tmp/openclaw-corrupt-plugin-before.json
)"
rm -f "$plugin_dir/package.json"
if [ -f "$plugin_dir/package.json" ]; then
echo "Expected corrupt plugin package.json to be removed before update." >&2
exit 1
fi
echo "Updating OpenClaw with corrupt plugin present..."
set +e
node "$entry" update --channel beta --tag "${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" --yes --no-restart --json >/tmp/openclaw-update-corrupt-plugin.json 2>/tmp/openclaw-update-corrupt-plugin.err
update_status=$?
set -e
if [ "$update_status" -ne 0 ]; then
if ! node scripts/e2e/lib/plugin-update/probe.mjs assert-legacy-post-update-plugin-failure /tmp/openclaw-update-corrupt-plugin.json; then
echo "openclaw update failed with corrupt plugin present" >&2
cat /tmp/openclaw-update-corrupt-plugin.err >&2 || true
cat /tmp/openclaw-update-corrupt-plugin.json >&2 || true
exit "$update_status"
fi
echo "Legacy updater reported post-update plugin failure after installing the new core; verifying updated entrypoint..."
set +e
OPENCLAW_UPDATE_POST_CORE=1 \
OPENCLAW_UPDATE_POST_CORE_CHANNEL=beta \
OPENCLAW_UPDATE_POST_CORE_RESULT_PATH=/tmp/openclaw-update-corrupt-plugin-post-core.json \
node "$entry" update --yes --no-restart --json >/tmp/openclaw-update-corrupt-plugin-post-core.stdout 2>/tmp/openclaw-update-corrupt-plugin-post-core.err
post_core_status=$?
set -e
if [ "$post_core_status" -ne 0 ]; then
echo "updated OpenClaw entry failed post-core plugin verification" >&2
cat /tmp/openclaw-update-corrupt-plugin-post-core.err >&2 || true
cat /tmp/openclaw-update-corrupt-plugin-post-core.stdout >&2 || true
cat /tmp/openclaw-update-corrupt-plugin-post-core.json >&2 || true
exit "$post_core_status"
fi
node scripts/e2e/lib/plugin-update/probe.mjs assert-corrupt-plugin-result /tmp/openclaw-update-corrupt-plugin-post-core.json demo-corrupt-plugin
exit 0
fi
node scripts/e2e/lib/plugin-update/probe.mjs assert-corrupt-update /tmp/openclaw-update-corrupt-plugin.json demo-corrupt-plugin

View File

@@ -112,7 +112,79 @@ function assertOutput(logPath) {
}
}
const [command, arg] = process.argv.slice(2);
function assertCorruptUpdate(updateJsonPath, pluginId) {
const payload = readJson(updateJsonPath);
if (payload.status !== "ok") {
throw new Error(`expected core update status ok, got ${JSON.stringify(payload.status)}`);
}
const plugins = payload.postUpdate?.plugins;
if (!plugins) {
throw new Error(`missing postUpdate.plugins in update output: ${JSON.stringify(payload)}`);
}
if (plugins.status !== "warning") {
throw new Error(
`expected post-update plugin status warning, got ${JSON.stringify(plugins.status)}`,
);
}
assertCorruptPluginDetails(plugins, pluginId);
}
function assertCorruptPluginResult(pluginJsonPath, pluginId) {
const plugins = readJson(pluginJsonPath);
if (plugins.status !== "warning") {
throw new Error(
`expected post-update plugin status warning, got ${JSON.stringify(plugins.status)}`,
);
}
assertCorruptPluginDetails(plugins, pluginId);
}
function assertCorruptPluginDetails(plugins, pluginId) {
const outcomes = plugins.npm?.outcomes ?? [];
const outcome = outcomes.find((entry) => entry?.pluginId === pluginId);
if (!outcome || outcome.status !== "error") {
throw new Error(
`expected error outcome for ${pluginId}, got ${JSON.stringify({
outcomes,
warnings: plugins.warnings ?? [],
sync: plugins.sync,
integrityDrifts: plugins.integrityDrifts ?? [],
})}`,
);
}
const warnings = plugins.warnings ?? [];
const warning = warnings.find((entry) => entry?.pluginId === pluginId);
if (!warning) {
throw new Error(`expected warning for ${pluginId}, got ${JSON.stringify(warnings)}`);
}
const text = JSON.stringify({ outcome, warning });
for (const expected of [
"package.json is missing",
"Run openclaw doctor --fix to attempt automatic repair.",
`Run openclaw plugins inspect ${pluginId} --runtime --json for details.`,
]) {
if (!text.includes(expected)) {
throw new Error(`expected update output to include ${expected}: ${text}`);
}
}
}
function assertLegacyPostUpdatePluginFailure(updateJsonPath) {
const payload = readJson(updateJsonPath);
if (payload.status !== "error" || payload.reason !== "post-update-plugins") {
throw new Error(
`expected legacy post-update plugin failure, got ${JSON.stringify({
status: payload.status,
reason: payload.reason,
})}`,
);
}
if (!payload.after?.version) {
throw new Error(`expected core update to install a new version: ${JSON.stringify(payload)}`);
}
}
const [command, arg, arg2] = process.argv.slice(2);
const commands = {
"legacy-compat": () => console.log(legacyPackageAcceptanceCompat(arg || "") ? "1" : "0"),
seed: seedInstallState,
@@ -120,6 +192,9 @@ const commands = {
snapshot: () => process.stdout.write(JSON.stringify(pluginRecordSnapshot(), null, 2)),
"assert-snapshot": () => assertSnapshot(arg),
"assert-output": () => assertOutput(arg),
"assert-corrupt-update": () => assertCorruptUpdate(arg, arg2),
"assert-corrupt-plugin-result": () => assertCorruptPluginResult(arg, arg2),
"assert-legacy-post-update-plugin-failure": () => assertLegacyPostUpdatePluginFailure(arg),
};
const run = commands[command];
await (

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Verifies `openclaw update` succeeds when a managed external plugin is corrupt.
# The lane installs an older published OpenClaw package, corrupts an npm-managed
# plugin payload, then updates to the prepared package artifact.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-update-corrupt-plugin-e2e" OPENCLAW_UPDATE_CORRUPT_PLUGIN_E2E_IMAGE)"
SKIP_BUILD="${OPENCLAW_UPDATE_CORRUPT_PLUGIN_E2E_SKIP_BUILD:-0}"
PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz update-corrupt-plugin "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}")"
# Bare lanes mount the package artifact instead of baking app sources into the image.
docker_e2e_package_mount_args "$PACKAGE_TGZ"
docker_e2e_build_or_reuse "$IMAGE_NAME" update-corrupt-plugin "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD"
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 update-corrupt-plugin empty)"
echo "Running corrupt plugin update tolerance E2E..."
docker_e2e_run_with_harness \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e OPENCLAW_SKIP_CHANNELS=1 \
-e OPENCLAW_SKIP_PROVIDERS=1 \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
"$IMAGE_NAME" \
bash scripts/e2e/lib/plugin-update/corrupt-update-scenario.sh
echo "Corrupt plugin update tolerance Docker E2E passed."

View File

@@ -273,6 +273,15 @@ export const mainLanes = [
npmLane("plugin-update", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugin-update", {
stateScenario: "empty",
}),
npmLane(
"update-corrupt-plugin",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:update-corrupt-plugin",
{
stateScenario: "empty",
timeoutMs: 30 * 60 * 1000,
weight: 3,
},
),
npmLane(
"plugin-lifecycle-matrix",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugin-lifecycle-matrix",