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

@@ -595,7 +595,7 @@ jobs:
artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
package_sha256: ${{ needs.prepare_release_package.outputs.package_sha256 }}
suite_profile: custom
docker_lanes: doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor update-restart-auth plugins-offline plugin-update
docker_lanes: doctor-switch update-channel-switch update-corrupt-plugin upgrade-survivor published-upgrade-survivor update-restart-auth plugins-offline plugin-update
published_upgrade_survivor_baselines: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'last-stable-4 2026.4.23 2026.5.2 2026.4.15' || '' }}
published_upgrade_survivor_scenarios: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'reported-issues' || '' }}
telegram_mode: mock-openai

View File

@@ -386,10 +386,10 @@ jobs:
docker_lanes="npm-onboard-channel-agent gateway-network config-reload"
;;
package)
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor update-restart-auth plugins-offline plugin-update"
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch update-corrupt-plugin upgrade-survivor published-upgrade-survivor update-restart-auth plugins-offline plugin-update"
;;
product)
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor update-restart-auth plugins plugin-update mcp-channels cron-mcp-cleanup openai-web-search-minimal openwebui"
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch update-corrupt-plugin upgrade-survivor published-upgrade-survivor update-restart-auth plugins plugin-update mcp-channels cron-mcp-cleanup openai-web-search-minimal openwebui"
include_openwebui=true
;;
full)

View File

@@ -245,7 +245,7 @@ Docs: https://docs.openclaw.ai
- Doctor/sessions: clear auto-created stale session routing state from the sessions store when `doctor --fix` sees plugin-owned model/runtime/auth/session bindings outside the current configured route, while leaving explicit user model choices for manual review. Refs #68615.
- CLI/sessions: prune old unreferenced transcript, compaction checkpoint, and trajectory artifacts during normal `sessions cleanup`, so gateway restart or crash orphans do not accumulate indefinitely outside `sessions.json`. Fixes #77608. Thanks @slideshow-dingo.
- CLI/sessions: cap `openclaw sessions` output to the newest 100 rows by default and add `--limit <n|all>` plus JSON pagination metadata, so repeated machine polling of large session stores cannot fan out into unbounded per-row enrichment/output work. Fixes #77500. Thanks @Kaotic3.
- CLI/update: disable and skip plugins that fail package-update plugin sync, so a broken npm/ClawHub/git/marketplace plugin cannot turn a successful OpenClaw package update into a failed update result. Thanks @vincentkoc.
- CLI/update: report corrupt or unloadable managed plugins as post-update warnings instead of disabling them or turning a successful OpenClaw package update into a failed update result. Thanks @vincentkoc and @Patrick-Erichsen.
- CLI/update: use an absolute POSIX npm script shell during package-manager updates, so restricted PATH environments can still run dependency lifecycle scripts while updating from `--tag main`. Fixes #77530. Thanks @PeterTremonti.
- CLI/update: make package-update follow-up processes write completion results and exit explicitly, so Windows packaged upgrades do not hang after the new package finishes post-core plugin work. Thanks @vincentkoc.
- CLI/update: stage pnpm-detected npm-layout global package updates through a clean npm prefix swap, keep plugin install runtime imports behind a stable alias, and ship legacy install-runtime aliases back to `2026.3.22`, preventing stale overlay chunks from breaking plugin post-update sync. Thanks @vincentkoc.

View File

@@ -38,8 +38,9 @@ openclaw --update
- `--tag <dist-tag|version|spec>`: override the package target for this update only. For package installs, `main` maps to `github:openclaw/openclaw#main`.
- `--dry-run`: preview planned update actions (channel/tag/target/restart flow) without writing config, installing, syncing plugins, or restarting.
- `--json`: print machine-readable `UpdateRunResult` JSON, including
`postUpdate.plugins.integrityDrifts` when npm plugin artifact drift is
detected during post-update plugin sync.
`postUpdate.plugins.warnings` when corrupt or unloadable managed plugins need
repair after the core update succeeds, and `postUpdate.plugins.integrityDrifts`
when npm plugin artifact drift is detected during post-update plugin sync.
- `--timeout <seconds>`: per-step timeout (default is 1800s).
- `--yes`: skip confirmation prompts (for example downgrade confirmation).
@@ -177,7 +178,7 @@ If an exact pinned npm plugin update resolves to an artifact whose integrity dif
</Warning>
<Note>
Post-update plugin sync failures fail the update result and stop restart follow-up work. Fix the plugin install or update error, then rerun `openclaw update`.
Post-update plugin sync failures that are scoped to a managed plugin are reported as warnings after the core update succeeds. The JSON result keeps the top-level update `status: "ok"` and reports `postUpdate.plugins.status: "warning"` with `openclaw doctor --fix` and `openclaw plugins inspect <id> --runtime --json` guidance. Unexpected updater or sync exceptions still fail the update result. Fix the plugin install or update error, then rerun `openclaw doctor --fix` or `openclaw update`.
When the updated Gateway starts, plugin loading is verify-only: startup does not run package managers or mutate dependency trees. Package-manager `update.run` restarts bypass the normal idle deferral and restart cooldown after the package tree has been swapped, so the old process cannot keep lazy-loading removed chunks.

View File

@@ -172,7 +172,7 @@ targets the shipped npm package instead.
Release checks call Package Acceptance with the package/update/restart/plugin set:
```text
doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor update-restart-auth plugins-offline plugin-update
doctor-switch update-channel-switch update-corrupt-plugin upgrade-survivor published-upgrade-survivor update-restart-auth plugins-offline plugin-update
```
When release soak is enabled, they also pass:
@@ -183,10 +183,10 @@ published_upgrade_survivor_scenarios=reported-issues
telegram_mode=mock-openai
```
This keeps package migration, update channel switching, stale plugin dependency
cleanup, offline plugin coverage, plugin update behavior, and Telegram package
QA on the same resolved artifact without making the default release package gate
walk every published release.
This keeps package migration, update channel switching, corrupt managed-plugin
tolerance, stale plugin dependency cleanup, offline plugin coverage, plugin
update behavior, and Telegram package QA on the same resolved artifact without
making the default release package gate walk every published release.
`last-stable-4` resolves to the four latest stable npm-published OpenClaw
releases. Release package acceptance pins `2026.4.23` as the first plugin-update

View File

@@ -1570,6 +1570,7 @@
"test:docker:session-runtime-context": "bash scripts/e2e/session-runtime-context-docker.sh",
"test:docker:timings": "node scripts/docker-e2e-timings.mjs",
"test:docker:update-channel-switch": "bash scripts/e2e/update-channel-switch-docker.sh",
"test:docker:update-corrupt-plugin": "bash scripts/e2e/update-corrupt-plugin-docker.sh",
"test:docker:update-migration": "env OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE=1 OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC=${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-openclaw@2026.4.23} OPENCLAW_UPGRADE_SURVIVOR_SCENARIO=${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-plugin-deps-cleanup} bash scripts/e2e/upgrade-survivor-docker.sh",
"test:docker:update-restart-auth": "env OPENCLAW_UPGRADE_SURVIVOR_UPDATE_RESTART_MODE=auto-auth OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT=${OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT:-1500s} bash scripts/e2e/upgrade-survivor-docker.sh",
"test:docker:upgrade-survivor": "bash scripts/e2e/upgrade-survivor-docker.sh",

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",

View File

@@ -979,7 +979,7 @@ describe("update-cli", () => {
expect(logs.join("\n")).toContain("Plugin update aborted");
});
it("fails json update output when post-core plugin updates fail", async () => {
it("keeps json update output successful when post-core plugin updates warn", async () => {
updateNpmInstalledPlugins.mockImplementationOnce(
async (params: {
config: OpenClawConfig;
@@ -1025,9 +1025,9 @@ describe("update-cli", () => {
const jsonOutput = vi.mocked(defaultRuntime.writeJson).mock.calls.at(-1)?.[0] as
| UpdateRunResult
| undefined;
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
expect(jsonOutput?.status).toBe("error");
expect(jsonOutput?.reason).toBe("post-update-plugins");
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
expect(jsonOutput?.status).toBe("ok");
expect(jsonOutput?.reason).toBeUndefined();
expect(jsonOutput?.postUpdate?.plugins?.integrityDrifts).toEqual([
{
pluginId: "demo",
@@ -1039,11 +1039,88 @@ describe("update-cli", () => {
action: "aborted",
},
]);
expect(jsonOutput?.postUpdate?.plugins?.status).toBe("error");
expect(jsonOutput?.postUpdate?.plugins?.status).toBe("warning");
expect(jsonOutput?.postUpdate?.plugins?.warnings?.[0]).toMatchObject({
pluginId: "demo",
guidance: [
"Run openclaw doctor --fix to attempt automatic repair.",
"Run openclaw plugins inspect demo --runtime --json for details.",
],
});
expect(jsonOutput?.postUpdate?.plugins?.warnings?.[0]?.reason).toContain(
"npm package integrity drift",
);
expect(jsonOutput?.postUpdate?.plugins?.npm.outcomes[0]?.status).toBe("error");
expect(jsonOutput?.postUpdate?.plugins?.npm.outcomes[0]?.message).toContain(
"Run openclaw doctor --fix to attempt automatic repair.",
);
expect(jsonOutput?.postUpdate?.plugins?.npm.outcomes[0]?.message).toContain(
"Run openclaw plugins inspect demo --runtime --json for details.",
);
});
it("fails before restart when post-core plugin updates fail", async () => {
it("detects missing plugin payloads from persisted records before npm updates", async () => {
const installPath = createCaseDir("openclaw-missing-plugin-payload");
fsSync.mkdirSync(installPath, { recursive: true });
const config = {
plugins: {
entries: {
demo: { enabled: true },
},
},
} as OpenClawConfig;
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
...baseSnapshot,
parsed: config,
resolved: config,
sourceConfig: config,
config,
runtimeConfig: config,
});
loadInstalledPluginIndexInstallRecords.mockResolvedValueOnce({
demo: {
source: "npm",
spec: "@openclaw/demo@1.0.0",
installPath,
},
});
syncPluginsForUpdateChannel.mockResolvedValueOnce({
changed: false,
config,
summary: {
switchedToBundled: [],
switchedToNpm: [],
warnings: [],
errors: [],
},
});
pathExists.mockImplementation(async (candidate: string) => candidate === installPath);
vi.mocked(defaultRuntime.writeJson).mockClear();
await updateCommand({ json: true, restart: false });
const updateCall = updateNpmInstalledPlugins.mock.calls.at(-1)?.[0] as
| { skipIds?: Set<string> }
| undefined;
expect(updateCall?.skipIds?.has("demo")).toBe(true);
const jsonOutput = vi.mocked(defaultRuntime.writeJson).mock.calls.at(-1)?.[0] as
| UpdateRunResult
| undefined;
expect(jsonOutput?.status).toBe("ok");
expect(jsonOutput?.postUpdate?.plugins?.status).toBe("warning");
expect(jsonOutput?.postUpdate?.plugins?.warnings?.[0]).toMatchObject({
pluginId: "demo",
});
expect(jsonOutput?.postUpdate?.plugins?.warnings?.[0]?.reason).toContain(
"package.json is missing",
);
expect(jsonOutput?.postUpdate?.plugins?.npm.outcomes[0]).toMatchObject({
pluginId: "demo",
status: "error",
});
});
it("prints non-fatal plugin warnings in human update output", async () => {
updateNpmInstalledPlugins.mockResolvedValueOnce({
changed: false,
config: baseConfig,
@@ -1055,11 +1132,10 @@ describe("update-cli", () => {
},
],
});
serviceLoaded.mockResolvedValue(true);
await updateCommand({ yes: true });
await updateCommand({ yes: true, restart: false });
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
expect(runDaemonInstall).not.toHaveBeenCalled();
expect(runDaemonRestart).not.toHaveBeenCalled();
expect(runRestartScript).not.toHaveBeenCalled();
@@ -1068,10 +1144,33 @@ describe("update-cli", () => {
.mocked(defaultRuntime.error)
.mock.calls.map((call) => String(call[0]))
.join("\n"),
).toContain("Update failed during plugin post-update sync.");
).not.toContain("Update failed during plugin post-update sync.");
const logs = vi
.mocked(defaultRuntime.log)
.mock.calls.map((call) => String(call[0]))
.join("\n");
expect(logs).toContain("Failed to update demo: registry timeout");
expect(logs).toContain("Run openclaw doctor --fix to attempt automatic repair.");
expect(logs).toContain("Run openclaw plugins inspect demo --runtime --json for details.");
});
it("preserves fresh-process plugin failure details in parent json output", async () => {
it("fails unexpected post-core plugin sync exceptions", async () => {
syncPluginsForUpdateChannel.mockRejectedValueOnce(new Error("plugin sync invariant broke"));
await expect(updateCommand({ json: true, restart: false })).rejects.toThrow(
"plugin sync invariant broke",
);
});
it("fails unexpected post-core npm update exceptions", async () => {
updateNpmInstalledPlugins.mockRejectedValueOnce(new Error("npm update invariant broke"));
await expect(updateCommand({ json: true, restart: false })).rejects.toThrow(
"npm update invariant broke",
);
});
it("preserves fresh-process plugin warning details in parent json output", async () => {
setupUpdatedRootRefresh();
spawn.mockImplementationOnce((_node, _argv, options) => {
const child = new EventEmitter() as EventEmitter & {
@@ -1084,8 +1183,20 @@ describe("update-cli", () => {
await fs.writeFile(
resultPath,
JSON.stringify({
status: "error",
status: "warning",
changed: false,
warnings: [
{
pluginId: "demo",
reason: "Failed to update demo: registry timeout",
message:
'Plugin "demo" could not be processed after the core update: Failed to update demo: registry timeout Run openclaw doctor --fix to attempt automatic repair. Run openclaw plugins inspect demo --runtime --json for details.',
guidance: [
"Run openclaw doctor --fix to attempt automatic repair.",
"Run openclaw plugins inspect demo --runtime --json for details.",
],
},
],
sync: {
changed: false,
switchedToBundled: [],
@@ -1108,7 +1219,7 @@ describe("update-cli", () => {
"utf-8",
);
}
child.emit("exit", 1, null);
child.emit("exit", 0, null);
});
return child;
});
@@ -1119,9 +1230,12 @@ describe("update-cli", () => {
const jsonOutput = vi.mocked(defaultRuntime.writeJson).mock.calls.at(-1)?.[0] as
| UpdateRunResult
| undefined;
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
expect(jsonOutput?.status).toBe("error");
expect(jsonOutput?.reason).toBe("post-update-plugins");
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
expect(jsonOutput?.status).toBe("ok");
expect(jsonOutput?.reason).toBeUndefined();
expect(jsonOutput?.postUpdate?.plugins?.warnings?.[0]?.guidance).toContain(
"Run openclaw doctor --fix to attempt automatic repair.",
);
expect(jsonOutput?.postUpdate?.plugins?.npm.outcomes[0]?.message).toContain("registry timeout");
});

View File

@@ -123,6 +123,7 @@ const POST_INSTALL_DOCTOR_SERVICE_ENV_KEYS = [
...SERVICE_REFRESH_PATH_ENV_KEYS,
"OPENCLAW_PROFILE",
] as const;
const POST_UPDATE_PLUGIN_REPAIR_GUIDANCE = "Run openclaw doctor --fix to attempt automatic repair.";
const UPDATE_QUIPS = [
"Leveled up! New skills unlocked. You're welcome.",
@@ -157,6 +158,8 @@ type MissingPluginInstallPayload = {
reason: "missing-install-path" | "missing-package-dir" | "missing-package-json";
};
type PostUpdatePluginWarning = NonNullable<PostCorePluginUpdateResult["warnings"]>[number];
function pickUpdateQuip(): string {
return UPDATE_QUIPS[Math.floor(Math.random() * UPDATE_QUIPS.length)] ?? "Update complete.";
}
@@ -240,6 +243,49 @@ function formatMissingPluginPayloadReason(entry: MissingPluginInstallPayload): s
return `package directory is missing: ${entry.installPath}`;
}
function formatPostUpdatePluginInspectGuidance(pluginId: string): string {
return `Run openclaw plugins inspect ${pluginId} --runtime --json for details.`;
}
function createPostUpdatePluginWarning(params: {
pluginId?: string;
reason: string;
}): PostUpdatePluginWarning {
const reason = params.reason.trim() || "unknown plugin post-update failure";
const guidance = [
POST_UPDATE_PLUGIN_REPAIR_GUIDANCE,
...(params.pluginId ? [formatPostUpdatePluginInspectGuidance(params.pluginId)] : []),
];
return {
...(params.pluginId ? { pluginId: params.pluginId } : {}),
reason,
message: params.pluginId
? `Plugin "${params.pluginId}" could not be processed after the core update: ${reason} ${guidance.join(" ")}`
: `Plugin post-update processing could not complete after the core update: ${reason} ${guidance.join(" ")}`,
guidance,
};
}
function createGuidedPostUpdatePluginOutcome(outcome: PluginUpdateOutcome): {
outcome: PluginUpdateOutcome;
warning?: PostUpdatePluginWarning;
} {
if (outcome.status !== "error") {
return { outcome };
}
const warning = createPostUpdatePluginWarning({
...(outcome.pluginId && outcome.pluginId !== "unknown" ? { pluginId: outcome.pluginId } : {}),
reason: outcome.message,
});
return {
outcome: {
...outcome,
message: warning.message,
},
warning,
};
}
export function shouldPrepareUpdatedInstallRestart(params: {
updateMode: UpdateRunResult["mode"];
serviceInstalled: boolean;
@@ -1050,6 +1096,7 @@ async function updatePluginsAfterCoreUpdate(params: {
outcomes: [],
},
integrityDrifts: [],
warnings: [],
};
}
@@ -1066,9 +1113,14 @@ async function updatePluginsAfterCoreUpdate(params: {
defaultRuntime.log(theme.heading("Updating plugins..."));
}
const warnings: PostUpdatePluginWarning[] = [];
const pluginInstallRecords = await loadInstalledPluginIndexInstallRecords();
const syncConfig = withPluginInstallRecords(
params.configSnapshot.sourceConfig,
pluginInstallRecords,
);
const syncResult = await syncPluginsForUpdateChannel({
config: withPluginInstallRecords(params.configSnapshot.sourceConfig, pluginInstallRecords),
config: syncConfig,
channel: params.channel,
workspaceDir: params.root,
externalizedBundledPluginBridges: await listPersistedBundledPluginLocationBridges({
@@ -1076,6 +1128,9 @@ async function updatePluginsAfterCoreUpdate(params: {
}),
logger: pluginLogger,
});
for (const error of syncResult.summary.errors) {
warnings.push(createPostUpdatePluginWarning({ reason: error }));
}
let pluginConfig = syncResult.config;
const integrityDrifts: PostCorePluginUpdateResult["integrityDrifts"] = [];
const pluginUpdateOutcomes: PluginUpdateOutcome[] = [];
@@ -1106,7 +1161,7 @@ async function updatePluginsAfterCoreUpdate(params: {
return false;
};
const repairMissingPayloads = async (
const collectMissingPayloadWarnings = async (
records: Record<string, PluginInstallRecord>,
): Promise<readonly string[]> => {
const missing = await collectMissingPluginInstallPayloads({
@@ -1118,50 +1173,45 @@ async function updatePluginsAfterCoreUpdate(params: {
return [];
}
const missingIds = missing.map((entry) => entry.pluginId);
if (!params.opts.json) {
defaultRuntime.log(
theme.warn(
`Recovering missing plugin install payloads: ${missing
.map((entry) => `${entry.pluginId} (${formatMissingPluginPayloadReason(entry)})`)
.join(", ")}.`,
),
);
for (const entry of missing) {
const warning = createPostUpdatePluginWarning({
pluginId: entry.pluginId,
reason: `Plugin install payload missing after update: ${formatMissingPluginPayloadReason(entry)}.`,
});
warnings.push(warning);
pluginUpdateOutcomes.push({
pluginId: entry.pluginId,
status: "error",
message: warning.message,
});
if (!params.opts.json) {
defaultRuntime.log(theme.warn(warning.message));
}
}
const repairResult = await updateNpmInstalledPlugins({
config: pluginConfig,
pluginIds: missingIds,
timeoutMs: params.timeoutMs,
updateChannel: params.channel,
skipDisabledPlugins: true,
disableOnFailure: true,
logger: pluginLogger,
onIntegrityDrift: onPluginIntegrityDrift,
});
pluginConfig = repairResult.config;
pluginsChanged ||= repairResult.changed;
npmPluginsChanged ||= repairResult.changed;
pluginUpdateOutcomes.push(...repairResult.outcomes);
return missingIds;
};
const repairedMissingPayloadIds = await repairMissingPayloads(
pluginConfig.plugins?.installs ?? {},
);
const missingPayloadIds = await collectMissingPayloadWarnings(pluginInstallRecords);
const npmResult = await updateNpmInstalledPlugins({
config: pluginConfig,
timeoutMs: params.timeoutMs,
updateChannel: params.channel,
skipIds: new Set([...syncResult.summary.switchedToNpm, ...repairedMissingPayloadIds]),
skipIds: new Set([...syncResult.summary.switchedToNpm, ...missingPayloadIds]),
skipDisabledPlugins: true,
disableOnFailure: true,
logger: pluginLogger,
onIntegrityDrift: onPluginIntegrityDrift,
});
pluginConfig = npmResult.config;
pluginsChanged ||= npmResult.changed;
npmPluginsChanged ||= npmResult.changed;
pluginUpdateOutcomes.push(...npmResult.outcomes);
for (const rawOutcome of npmResult.outcomes) {
const guided = createGuidedPostUpdatePluginOutcome(rawOutcome);
pluginUpdateOutcomes.push(guided.outcome);
if (guided.warning) {
warnings.push(guided.warning);
}
}
const remainingMissingPayloads = await collectMissingPluginInstallPayloads({
records: pluginConfig.plugins?.installs ?? {},
@@ -1169,13 +1219,20 @@ async function updatePluginsAfterCoreUpdate(params: {
skipDisabledPlugins: true,
});
pluginUpdateOutcomes.push(
...remainingMissingPayloads.map(
(entry): PluginUpdateOutcome => ({
pluginId: entry.pluginId,
status: "error",
message: `Plugin install payload missing after update: ${formatMissingPluginPayloadReason(entry)}.`,
...remainingMissingPayloads
.filter((entry) => !missingPayloadIds.includes(entry.pluginId))
.map((entry): PluginUpdateOutcome => {
const warning = createPostUpdatePluginWarning({
pluginId: entry.pluginId,
reason: `Plugin install payload missing after update: ${formatMissingPluginPayloadReason(entry)}.`,
});
warnings.push(warning);
return {
pluginId: entry.pluginId,
status: "error",
message: warning.message,
};
}),
),
);
if (pluginsChanged) {
@@ -1198,12 +1255,9 @@ async function updatePluginsAfterCoreUpdate(params: {
if (params.opts.json) {
return {
status:
syncResult.summary.errors.length > 0 ||
pluginUpdateOutcomes.some((outcome) => outcome.status === "error")
? "error"
: "ok",
status: warnings.length > 0 ? "warning" : "ok",
changed: pluginsChanged,
warnings,
sync: {
changed: syncResult.changed,
switchedToBundled: syncResult.summary.switchedToBundled,
@@ -1242,7 +1296,7 @@ async function updatePluginsAfterCoreUpdate(params: {
defaultRuntime.log(theme.warn(warning));
}
for (const error of syncResult.summary.errors) {
defaultRuntime.log(theme.error(error));
defaultRuntime.log(theme.warn(createPostUpdatePluginWarning({ reason: error }).message));
}
const updated = pluginUpdateOutcomes.filter((entry) => entry.status === "updated").length;
@@ -1267,16 +1321,13 @@ async function updatePluginsAfterCoreUpdate(params: {
if (outcome.status !== "error") {
continue;
}
defaultRuntime.log(theme.error(outcome.message));
defaultRuntime.log(theme.warn(outcome.message));
}
return {
status:
syncResult.summary.errors.length > 0 ||
pluginUpdateOutcomes.some((outcome) => outcome.status === "error")
? "error"
: "ok",
status: warnings.length > 0 ? "warning" : "ok",
changed: pluginsChanged,
warnings,
sync: {
changed: syncResult.changed,
switchedToBundled: syncResult.summary.switchedToBundled,
@@ -1626,7 +1677,10 @@ async function readPostCorePluginUpdateResultFile(
if (
parsed &&
typeof parsed === "object" &&
(parsed.status === "ok" || parsed.status === "skipped" || parsed.status === "error")
(parsed.status === "ok" ||
parsed.status === "warning" ||
parsed.status === "skipped" ||
parsed.status === "error")
) {
return parsed;
}
@@ -1854,10 +1908,6 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
defaultRuntime.writeJson(result);
}
}
if (pluginUpdate.status === "error") {
defaultRuntime.exit(1);
return;
}
defaultRuntime.exit(0);
return;
}

View File

@@ -907,14 +907,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(mocks.updateNpmInstalledPlugins).not.toHaveBeenCalled();
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled();
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
{},
{
env: {
OPENCLAW_UPDATE_IN_PROGRESS: "1",
},
},
);
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
expect(result).toEqual({
changes: [
'Skipped package-manager repair for configured plugin "discord" during package update; rerun "openclaw doctor --fix" after the update completes.',
@@ -959,14 +952,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(mocks.updateNpmInstalledPlugins).not.toHaveBeenCalled();
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled();
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
{},
{
env: {
OPENCLAW_UPDATE_IN_PROGRESS: "1",
},
},
);
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
expect(result).toEqual({
changes: [
'Skipped package-manager repair for configured plugin "discord" during package update; rerun "openclaw doctor --fix" after the update completes.',

View File

@@ -698,10 +698,6 @@ async function repairMissingPluginInstalls(params: {
if (!record || !isInstalledRecordMissingOnDisk(record, env)) {
continue;
}
if (nextRecords === records) {
nextRecords = { ...records };
}
delete nextRecords[pluginId];
changes.push(
`Skipped package-manager repair for configured plugin "${pluginId}" during package update; rerun "openclaw doctor --fix" after the update completes.`,
);
@@ -710,6 +706,7 @@ async function repairMissingPluginInstalls(params: {
const missingRecordedPluginIds = Object.keys(records).filter(
(pluginId) =>
!deferredPluginIds.has(pluginId) &&
Object.hasOwn(nextRecords, pluginId) &&
!bundledPluginsById.has(pluginId) &&
((params.pluginIds.has(pluginId) &&

View File

@@ -58,9 +58,15 @@ export type UpdateRunResult = {
durationMs: number;
postUpdate?: {
plugins?: {
status: "ok" | "skipped" | "error";
status: "ok" | "warning" | "skipped" | "error";
reason?: string;
changed: boolean;
warnings?: Array<{
pluginId?: string;
reason: string;
message: string;
guidance: string[];
}>;
sync: {
changed: boolean;
switchedToBundled: string[];

View File

@@ -141,12 +141,15 @@ function mergeRecoveredManagedNpmInstallRecords(
function extractPluginInstallRecordsFromPersistedInstalledPluginIndex(
index: unknown,
): Record<string, PluginInstallRecord> | null {
if (!isRecord(index) || !Array.isArray(index.plugins)) {
if (!isRecord(index)) {
return null;
}
if (Object.prototype.hasOwnProperty.call(index, "installRecords")) {
return readRecordMap(index.installRecords) ?? {};
}
if (!Array.isArray(index.plugins)) {
return null;
}
const records: Record<string, PluginInstallRecord> = {};
for (const entry of index.plugins) {
if (!isRecord(entry) || typeof entry.pluginId !== "string" || !isRecord(entry.installRecord)) {

View File

@@ -219,6 +219,33 @@ describe("plugin index install records store", () => {
});
});
it("reads legacy persisted records when the plugin index has no plugin list", async () => {
const stateDir = makeStateDir();
const indexPath = resolveInstalledPluginIndexRecordsStorePath({ stateDir });
fs.mkdirSync(path.dirname(indexPath), { recursive: true });
fs.writeFileSync(
indexPath,
JSON.stringify({
installRecords: {
legacy: {
source: "npm",
spec: "legacy@1.0.0",
installPath: path.join(stateDir, "plugins", "legacy"),
},
},
}),
"utf8",
);
await expect(loadInstalledPluginIndexInstallRecords({ stateDir })).resolves.toEqual({
legacy: {
source: "npm",
spec: "legacy@1.0.0",
installPath: path.join(stateDir, "plugins", "legacy"),
},
});
});
it("recovers managed npm plugin records when the persisted ledger is empty", async () => {
const stateDir = makeStateDir();
const discordDir = writeManagedNpmPlugin({

View File

@@ -927,7 +927,16 @@ export async function updateNpmInstalledPlugins(params: {
recordFailure(pluginId, `Invalid install path for "${pluginId}": ${String(err)}`);
continue;
}
const currentVersion = await readInstalledPackageVersion(installPath);
let currentVersion: string | undefined;
try {
currentVersion = await readInstalledPackageVersion(installPath);
} catch (err) {
recordFailure(
pluginId,
`Failed to inspect installed package for ${pluginId}: ${String(err)}`,
);
continue;
}
const extensionsDir = resolveRecordedExtensionsDir({
pluginId,
installPath,

View File

@@ -103,7 +103,7 @@ describe("package acceptance workflow", () => {
expect(workflow).toContain('"all-since-"');
expect(workflow).toContain("npm-onboard-channel-agent gateway-network config-reload");
expect(workflow).toContain("npm-onboard-channel-agent doctor-switch");
expect(workflow).toContain("update-channel-switch upgrade-survivor");
expect(workflow).toContain("update-channel-switch update-corrupt-plugin upgrade-survivor");
expect(workflow).toContain("published-upgrade-survivor");
expect(workflow).toContain("published-upgrade-survivor update-restart-auth");
expect(workflow).toContain("plugins-offline plugin-update");
@@ -547,7 +547,7 @@ describe("package artifact reuse", () => {
);
expect(workflow).toContain("suite_profile: custom");
expect(workflow).toContain(
"docker_lanes: doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor update-restart-auth plugins-offline plugin-update",
"docker_lanes: doctor-switch update-channel-switch update-corrupt-plugin upgrade-survivor published-upgrade-survivor update-restart-auth plugins-offline plugin-update",
);
expect(workflow).toContain(
"published_upgrade_survivor_baselines: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'last-stable-4 2026.4.23 2026.5.2 2026.4.15' || '' }}",