From 4820b701a597cedf646d6283579d14c7d9547ec0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 21:24:14 +0100 Subject: [PATCH] fix(plugins): fall back from invalid beta npm updates --- CHANGELOG.md | 6 +++ docs/cli/update.md | 5 ++- docs/plugins/manage-plugins.md | 4 +- src/plugins/update.test.ts | 81 ++++++++++++++++++++++++++++++++++ src/plugins/update.ts | 74 ++++++++++++++++++++++--------- 5 files changed, 145 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0fdb59160e..d95a6336389 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,11 @@ Docs: https://docs.openclaw.ai - Discord/status: add degraded Discord transport and gateway event-loop starvation signals to `openclaw channels status`, `openclaw status --deep`, and fetch-timeout logs so intermittent socket resets do not look like a healthy running channel. (#76327) Thanks @joshavant. - Providers/OpenRouter: add opt-in response caching params that send OpenRouter's `X-OpenRouter-Cache`, `X-OpenRouter-Cache-TTL`, and cache-clear headers only on verified OpenRouter routes. Thanks @vincentkoc. - Providers/OpenRouter: expand app-attribution categories so OpenClaw advertises coding, programming, writing, chat, and personal-agent usage on verified OpenRouter routes. Thanks @vincentkoc. +- Plugins/update: make package upgrades swap pnpm/npm-prefix installs cleanly, keep legacy plugin install runtime chunks working, and on the beta channel fall back default-line npm plugins to default/latest when plugin beta releases are missing or fail install validation. Thanks @vincentkoc and @joshavant. +- Channels/WhatsApp: support explicit WhatsApp Channel/Newsletter `@newsletter` outbound message targets with channel session metadata instead of DM routing. Fixes #13417; carries forward the narrow outbound target idea from #13424. Thanks @vincentkoc and @agentz-manfred. +- Exec approvals: add a tree-sitter-backed shell command explainer for future approval and command-review surfaces. (#75004) Thanks @jesse-merhi. +- Agents/sandbox: store sandbox container and browser registry entries as per-runtime shard files, reducing unrelated session lock contention while `openclaw doctor --fix` migrates legacy monolithic registry files. (#74831) Thanks @luckylhb90. +- Plugins/ClawHub: annotate 429 errors from ClawHub with the reset window from `RateLimit-Reset`/`Retry-After` and append a `Sign in for higher rate limits.` hint when the request was unauthenticated, so users can see when downloads will recover and how to lift the cap. Thanks @romneyda. - Plugins/runtime state: add `registerIfAbsent` for atomic keyed-store dedupe claims that return whether a plugin successfully claimed a key without overwriting an existing live value. Thanks @amknight. - Plugin SDK: add plugin-owned `SessionEntry` slot projection and scoped trusted-policy session extension reads. (#75609; replaces part of #73384/#74483) Thanks @100yenadmin. @@ -67,6 +72,7 @@ Docs: https://docs.openclaw.ai - Gateway/status: label Linux managed gateway services as `systemd user`, making status output explicit about the user-service scope instead of implying a system-level unit. Thanks @vincentkoc. - Plugins/install: remove the previous managed plugin directory when a reinstall switches sources, so stale ClawHub and npm copies no longer keep duplicate plugin ids in discovery after the new install wins. Thanks @vincentkoc. - Plugins/install: let official plugin reinstall recovery repair source-only installed runtime shadows, so `openclaw plugins install npm:@openclaw/discord --force` can replace the bad package instead of stopping at stale config validation. 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. - Plugins/commands: allow the official ClawHub Codex plugin package to keep reserved `/codex` command ownership, matching the existing npm-managed Codex package behavior. Thanks @vincentkoc. - Auth/OpenAI Codex: rewrite invalidated per-agent Codex auth-order and session profile overrides toward a healthy relogin profile, so revoked OAuth accounts do not stay pinned after signing in again. Thanks @BunsDev. - Plugins/commands: scope QQBot framework slash commands to the QQBot channel so `/bot-*` command handlers and native specs do not leak onto unrelated chat surfaces. Thanks @vincentkoc. diff --git a/docs/cli/update.md b/docs/cli/update.md index 60a1269b1fb..f5cd9467ee7 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -168,8 +168,9 @@ manually. On the beta update channel, tracked npm and ClawHub plugin installs that follow the default/latest line try a plugin `@beta` release first. If the plugin has no -beta release, OpenClaw falls back to the recorded default/latest spec. Exact -versions and explicit tags are not rewritten. +beta release, OpenClaw falls back to the recorded default/latest spec. For npm +plugins, OpenClaw also falls back when the beta package exists but fails install +validation. Exact versions and explicit tags are not rewritten. If an exact pinned npm plugin update resolves to an artifact whose integrity differs from the stored install record, `openclaw update` aborts that plugin artifact update instead of installing it. Reinstall or update the plugin explicitly only after verifying that you trust the new artifact. diff --git a/docs/plugins/manage-plugins.md b/docs/plugins/manage-plugins.md index dbb1061527b..1384983e2af 100644 --- a/docs/plugins/manage-plugins.md +++ b/docs/plugins/manage-plugins.md @@ -92,7 +92,9 @@ when it was previously pinned to an exact version or tag. When `openclaw update` runs on the beta channel, default-line npm and ClawHub plugin records try the matching plugin `@beta` release first. If that beta release does not exist, OpenClaw falls back to the recorded default/latest spec. -Exact versions and explicit tags such as `@rc` or `@beta` are preserved. +For npm plugins, OpenClaw also falls back when the beta package exists but fails +install validation. Exact versions and explicit tags such as `@rc` or `@beta` +are preserved. ## Uninstall plugins diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 1f44d43c48f..91f9028448a 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -1293,6 +1293,87 @@ describe("updateNpmInstalledPlugins", () => { }); }); + it("falls back to the default npm spec when the beta package exists but is invalid", async () => { + installPluginFromNpmSpecMock + .mockResolvedValueOnce({ + ok: false, + error: "Installed plugin package uses a TypeScript entry without compiled runtime output.", + }) + .mockResolvedValueOnce( + createSuccessfulNpmUpdateResult({ + pluginId: "openclaw-codex-app-server", + targetDir: "/tmp/openclaw-codex-app-server", + version: "0.2.6", + npmResolution: { + name: "openclaw-codex-app-server", + version: "0.2.6", + resolvedSpec: "openclaw-codex-app-server@0.2.6", + }, + }), + ); + + const warnMessages: string[] = []; + const result = await updateNpmInstalledPlugins({ + config: createCodexAppServerInstallConfig({ + spec: "openclaw-codex-app-server", + }), + pluginIds: ["openclaw-codex-app-server"], + updateChannel: "beta", + logger: { warn: (msg) => warnMessages.push(msg) }, + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + spec: "openclaw-codex-app-server@beta", + }), + ); + expect(installPluginFromNpmSpecMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + spec: "openclaw-codex-app-server", + }), + ); + expect(warnMessages).toEqual([expect.stringContaining("failed beta npm update")]); + expectCodexAppServerInstallState({ + result, + spec: "openclaw-codex-app-server", + version: "0.2.6", + resolvedSpec: "openclaw-codex-app-server@0.2.6", + }); + }); + + it("reports the fallback npm spec when beta fallback also fails", async () => { + installPluginFromNpmSpecMock + .mockResolvedValueOnce({ + ok: false, + error: "Installed plugin package uses a TypeScript entry without compiled runtime output.", + }) + .mockResolvedValueOnce({ + ok: false, + code: "npm_package_not_found", + error: "npm package not found", + }); + + const result = await updateNpmInstalledPlugins({ + config: createCodexAppServerInstallConfig({ + spec: "openclaw-codex-app-server", + }), + pluginIds: ["openclaw-codex-app-server"], + updateChannel: "beta", + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledTimes(2); + expect(result.outcomes).toEqual([ + { + pluginId: "openclaw-codex-app-server", + status: "error", + message: + "Failed to update openclaw-codex-app-server: npm package not found for openclaw-codex-app-server.", + }, + ]); + }); + it("preserves explicit npm tags when updating on the beta channel", async () => { installPluginFromNpmSpecMock.mockResolvedValue( createSuccessfulNpmUpdateResult({ diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 4d7f098185c..2ca5508ab84 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -431,13 +431,31 @@ function shouldFallbackBetaClawHubUpdate(result: { ok: false; code?: string }): return shouldFallbackClawHubBridgeToNpm(result); } -function shouldFallbackBetaNpmUpdate(result: { ok: false; code?: string; error: string }): boolean { - if (result.code === PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND) { - return true; +function describeBetaNpmFallback(params: { + pluginId: string; + betaSpec: string | undefined; + fallbackSpec: string; + result: { ok: false; code?: string; error: string }; +}): string { + const betaSpec = params.betaSpec ?? "the beta npm release"; + const missingBeta = + params.result.code === PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND || + /\b(ETARGET|notarget)\b|No matching version found|dist-tag|tag .*not found/i.test( + params.result.error, + ); + const reason = missingBeta ? "has no beta npm release" : "failed beta npm update"; + return `Plugin "${params.pluginId}" ${reason} for ${betaSpec}; falling back to ${params.fallbackSpec}.`; +} + +function npmUpdateFailureSpec(params: { + effectiveSpec: string | undefined; + fallbackSpec: string | undefined; + usedFallback: boolean; +}): string { + if (params.usedFallback && params.fallbackSpec) { + return params.fallbackSpec; } - return /\b(ETARGET|notarget)\b|No matching version found|dist-tag|tag .*not found/i.test( - result.error, - ); + return params.effectiveSpec ?? params.fallbackSpec ?? "unknown"; } function isDefaultNpmSpecForBetaUpdate(spec: string): { name: string } | null { @@ -1026,15 +1044,17 @@ export async function updateNpmInstalledPlugins(params: { }); continue; } - if ( - !probe.ok && - record.source === "npm" && - npmSpecs?.fallbackSpec && - shouldFallbackBetaNpmUpdate(probe) - ) { + let usedNpmFallback = false; + if (!probe.ok && record.source === "npm" && npmSpecs?.fallbackSpec) { logger.warn?.( - `Plugin "${pluginId}" has no beta npm release for ${npmSpecs.fallbackLabel ?? effectiveSpec}; falling back to ${npmSpecs.fallbackSpec}.`, + describeBetaNpmFallback({ + pluginId, + betaSpec: npmSpecs.fallbackLabel ?? effectiveSpec, + fallbackSpec: npmSpecs.fallbackSpec, + result: probe, + }), ); + usedNpmFallback = true; probe = await installPluginFromNpmSpec({ spec: npmSpecs.fallbackSpec, mode: "update", @@ -1083,7 +1103,11 @@ export async function updateNpmInstalledPlugins(params: { record.source === "npm" ? formatNpmInstallFailure({ pluginId, - spec: effectiveSpec!, + spec: npmUpdateFailureSpec({ + effectiveSpec, + fallbackSpec: npmSpecs?.fallbackSpec, + usedFallback: usedNpmFallback, + }), phase: "check", result: probe, }) @@ -1207,15 +1231,17 @@ export async function updateNpmInstalledPlugins(params: { }); continue; } - if ( - !result.ok && - record.source === "npm" && - npmSpecs?.fallbackSpec && - shouldFallbackBetaNpmUpdate(result) - ) { + let usedNpmFallback = false; + if (!result.ok && record.source === "npm" && npmSpecs?.fallbackSpec) { logger.warn?.( - `Plugin "${pluginId}" has no beta npm release for ${npmSpecs.fallbackLabel ?? effectiveSpec}; falling back to ${npmSpecs.fallbackSpec}.`, + describeBetaNpmFallback({ + pluginId, + betaSpec: npmSpecs.fallbackLabel ?? effectiveSpec, + fallbackSpec: npmSpecs.fallbackSpec, + result, + }), ); + usedNpmFallback = true; result = await installPluginFromNpmSpec({ spec: npmSpecs.fallbackSpec, mode: "update", @@ -1262,7 +1288,11 @@ export async function updateNpmInstalledPlugins(params: { record.source === "npm" ? formatNpmInstallFailure({ pluginId, - spec: effectiveSpec!, + spec: npmUpdateFailureSpec({ + effectiveSpec, + fallbackSpec: npmSpecs?.fallbackSpec, + usedFallback: usedNpmFallback, + }), phase: "update", result: result, })