diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index 23edfad3cfa..8e243186504 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -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 diff --git a/.github/workflows/package-acceptance.yml b/.github/workflows/package-acceptance.yml index 05c376ab219..c54e83461dc 100644 --- a/.github/workflows/package-acceptance.yml +++ b/.github/workflows/package-acceptance.yml @@ -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) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7234cde15e..38585c6613a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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. diff --git a/docs/cli/update.md b/docs/cli/update.md index f5cd9467ee7..31fec6186a9 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -38,8 +38,9 @@ openclaw --update - `--tag `: 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 `: 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 -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 --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. diff --git a/docs/help/testing-updates-plugins.md b/docs/help/testing-updates-plugins.md index adb20cf83b3..4a83ea2ed91 100644 --- a/docs/help/testing-updates-plugins.md +++ b/docs/help/testing-updates-plugins.md @@ -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 diff --git a/package.json b/package.json index 72ab2893a0d..0688ff9732c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/e2e/lib/plugin-update/corrupt-update-scenario.sh b/scripts/e2e/lib/plugin-update/corrupt-update-scenario.sh new file mode 100644 index 00000000000..87da071ad95 --- /dev/null +++ b/scripts/e2e/lib/plugin-update/corrupt-update-scenario.sh @@ -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 diff --git a/scripts/e2e/lib/plugin-update/probe.mjs b/scripts/e2e/lib/plugin-update/probe.mjs index 2bcc451115d..580cb1b3863 100644 --- a/scripts/e2e/lib/plugin-update/probe.mjs +++ b/scripts/e2e/lib/plugin-update/probe.mjs @@ -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 ( diff --git a/scripts/e2e/update-corrupt-plugin-docker.sh b/scripts/e2e/update-corrupt-plugin-docker.sh new file mode 100644 index 00000000000..56ba9f965a0 --- /dev/null +++ b/scripts/e2e/update-corrupt-plugin-docker.sh @@ -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." diff --git a/scripts/lib/docker-e2e-scenarios.mjs b/scripts/lib/docker-e2e-scenarios.mjs index 80ca8937448..b7e1caa74ad 100644 --- a/scripts/lib/docker-e2e-scenarios.mjs +++ b/scripts/lib/docker-e2e-scenarios.mjs @@ -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", diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 90590a5475a..add4d9be1f9 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -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 } + | 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"); }); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 1a5d5678ffd..1cd1e038069 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -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[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, ): Promise => { 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 { defaultRuntime.writeJson(result); } } - if (pluginUpdate.status === "error") { - defaultRuntime.exit(1); - return; - } defaultRuntime.exit(0); return; } diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index db88cb0273d..1531d1ba597 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -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.', diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index f6fea05a6e2..2b852c54205 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -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) && diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index a22ec0832cb..8fc4410bb31 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -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[]; diff --git a/src/plugins/installed-plugin-index-record-reader.ts b/src/plugins/installed-plugin-index-record-reader.ts index c2de72d7035..34a6a49e030 100644 --- a/src/plugins/installed-plugin-index-record-reader.ts +++ b/src/plugins/installed-plugin-index-record-reader.ts @@ -141,12 +141,15 @@ function mergeRecoveredManagedNpmInstallRecords( function extractPluginInstallRecordsFromPersistedInstalledPluginIndex( index: unknown, ): Record | 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 = {}; for (const entry of index.plugins) { if (!isRecord(entry) || typeof entry.pluginId !== "string" || !isRecord(entry.installRecord)) { diff --git a/src/plugins/installed-plugin-index-records.test.ts b/src/plugins/installed-plugin-index-records.test.ts index 666d56b4657..58a69ebf33e 100644 --- a/src/plugins/installed-plugin-index-records.test.ts +++ b/src/plugins/installed-plugin-index-records.test.ts @@ -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({ diff --git a/src/plugins/update.ts b/src/plugins/update.ts index de3f0c870d0..d1b36e0221e 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -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, diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 67e8c77174f..c31f07f9797 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -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' || '' }}",