Skip to content

Commit 4820b70

Browse files
committed
fix(plugins): fall back from invalid beta npm updates
1 parent 0fc8afe commit 4820b70

5 files changed

Lines changed: 145 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ Docs: https://docs.openclaw.ai
4242
- 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.
4343
- 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.
4444
- Providers/OpenRouter: expand app-attribution categories so OpenClaw advertises coding, programming, writing, chat, and personal-agent usage on verified OpenRouter routes. Thanks @vincentkoc.
45+
- 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.
46+
- 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.
47+
- Exec approvals: add a tree-sitter-backed shell command explainer for future approval and command-review surfaces. (#75004) Thanks @jesse-merhi.
48+
- 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.
49+
- 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.
4550
- 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.
4651
- Plugin SDK: add plugin-owned `SessionEntry` slot projection and scoped trusted-policy session extension reads. (#75609; replaces part of #73384/#74483) Thanks @100yenadmin.
4752

@@ -67,6 +72,7 @@ Docs: https://docs.openclaw.ai
6772
- 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.
6873
- 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.
6974
- 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.
75+
- 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.
7076
- 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.
7177
- 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.
7278
- 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.

docs/cli/update.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,9 @@ manually.
168168

169169
On the beta update channel, tracked npm and ClawHub plugin installs that follow
170170
the default/latest line try a plugin `@beta` release first. If the plugin has no
171-
beta release, OpenClaw falls back to the recorded default/latest spec. Exact
172-
versions and explicit tags are not rewritten.
171+
beta release, OpenClaw falls back to the recorded default/latest spec. For npm
172+
plugins, OpenClaw also falls back when the beta package exists but fails install
173+
validation. Exact versions and explicit tags are not rewritten.
173174

174175
<Warning>
175176
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.

docs/plugins/manage-plugins.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,9 @@ when it was previously pinned to an exact version or tag.
9292
When `openclaw update` runs on the beta channel, default-line npm and ClawHub
9393
plugin records try the matching plugin `@beta` release first. If that beta
9494
release does not exist, OpenClaw falls back to the recorded default/latest spec.
95-
Exact versions and explicit tags such as `@rc` or `@beta` are preserved.
95+
For npm plugins, OpenClaw also falls back when the beta package exists but fails
96+
install validation. Exact versions and explicit tags such as `@rc` or `@beta`
97+
are preserved.
9698

9799
## Uninstall plugins
98100

src/plugins/update.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1293,6 +1293,87 @@ describe("updateNpmInstalledPlugins", () => {
12931293
});
12941294
});
12951295

1296+
it("falls back to the default npm spec when the beta package exists but is invalid", async () => {
1297+
installPluginFromNpmSpecMock
1298+
.mockResolvedValueOnce({
1299+
ok: false,
1300+
error: "Installed plugin package uses a TypeScript entry without compiled runtime output.",
1301+
})
1302+
.mockResolvedValueOnce(
1303+
createSuccessfulNpmUpdateResult({
1304+
pluginId: "openclaw-codex-app-server",
1305+
targetDir: "/tmp/openclaw-codex-app-server",
1306+
version: "0.2.6",
1307+
npmResolution: {
1308+
name: "openclaw-codex-app-server",
1309+
version: "0.2.6",
1310+
resolvedSpec: "openclaw-codex-app-server@0.2.6",
1311+
},
1312+
}),
1313+
);
1314+
1315+
const warnMessages: string[] = [];
1316+
const result = await updateNpmInstalledPlugins({
1317+
config: createCodexAppServerInstallConfig({
1318+
spec: "openclaw-codex-app-server",
1319+
}),
1320+
pluginIds: ["openclaw-codex-app-server"],
1321+
updateChannel: "beta",
1322+
logger: { warn: (msg) => warnMessages.push(msg) },
1323+
});
1324+
1325+
expect(installPluginFromNpmSpecMock).toHaveBeenNthCalledWith(
1326+
1,
1327+
expect.objectContaining({
1328+
spec: "openclaw-codex-app-server@beta",
1329+
}),
1330+
);
1331+
expect(installPluginFromNpmSpecMock).toHaveBeenNthCalledWith(
1332+
2,
1333+
expect.objectContaining({
1334+
spec: "openclaw-codex-app-server",
1335+
}),
1336+
);
1337+
expect(warnMessages).toEqual([expect.stringContaining("failed beta npm update")]);
1338+
expectCodexAppServerInstallState({
1339+
result,
1340+
spec: "openclaw-codex-app-server",
1341+
version: "0.2.6",
1342+
resolvedSpec: "openclaw-codex-app-server@0.2.6",
1343+
});
1344+
});
1345+
1346+
it("reports the fallback npm spec when beta fallback also fails", async () => {
1347+
installPluginFromNpmSpecMock
1348+
.mockResolvedValueOnce({
1349+
ok: false,
1350+
error: "Installed plugin package uses a TypeScript entry without compiled runtime output.",
1351+
})
1352+
.mockResolvedValueOnce({
1353+
ok: false,
1354+
code: "npm_package_not_found",
1355+
error: "npm package not found",
1356+
});
1357+
1358+
const result = await updateNpmInstalledPlugins({
1359+
config: createCodexAppServerInstallConfig({
1360+
spec: "openclaw-codex-app-server",
1361+
}),
1362+
pluginIds: ["openclaw-codex-app-server"],
1363+
updateChannel: "beta",
1364+
});
1365+
1366+
expect(installPluginFromNpmSpecMock).toHaveBeenCalledTimes(2);
1367+
expect(result.outcomes).toEqual([
1368+
{
1369+
pluginId: "openclaw-codex-app-server",
1370+
status: "error",
1371+
message:
1372+
"Failed to update openclaw-codex-app-server: npm package not found for openclaw-codex-app-server.",
1373+
},
1374+
]);
1375+
});
1376+
12961377
it("preserves explicit npm tags when updating on the beta channel", async () => {
12971378
installPluginFromNpmSpecMock.mockResolvedValue(
12981379
createSuccessfulNpmUpdateResult({

src/plugins/update.ts

Lines changed: 52 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -431,13 +431,31 @@ function shouldFallbackBetaClawHubUpdate(result: { ok: false; code?: string }):
431431
return shouldFallbackClawHubBridgeToNpm(result);
432432
}
433433

434-
function shouldFallbackBetaNpmUpdate(result: { ok: false; code?: string; error: string }): boolean {
435-
if (result.code === PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND) {
436-
return true;
434+
function describeBetaNpmFallback(params: {
435+
pluginId: string;
436+
betaSpec: string | undefined;
437+
fallbackSpec: string;
438+
result: { ok: false; code?: string; error: string };
439+
}): string {
440+
const betaSpec = params.betaSpec ?? "the beta npm release";
441+
const missingBeta =
442+
params.result.code === PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND ||
443+
/\b(ETARGET|notarget)\b|No matching version found|dist-tag|tag .*not found/i.test(
444+
params.result.error,
445+
);
446+
const reason = missingBeta ? "has no beta npm release" : "failed beta npm update";
447+
return `Plugin "${params.pluginId}" ${reason} for ${betaSpec}; falling back to ${params.fallbackSpec}.`;
448+
}
449+
450+
function npmUpdateFailureSpec(params: {
451+
effectiveSpec: string | undefined;
452+
fallbackSpec: string | undefined;
453+
usedFallback: boolean;
454+
}): string {
455+
if (params.usedFallback && params.fallbackSpec) {
456+
return params.fallbackSpec;
437457
}
438-
return /\b(ETARGET|notarget)\b|No matching version found|dist-tag|tag .*not found/i.test(
439-
result.error,
440-
);
458+
return params.effectiveSpec ?? params.fallbackSpec ?? "unknown";
441459
}
442460

443461
function isDefaultNpmSpecForBetaUpdate(spec: string): { name: string } | null {
@@ -1026,15 +1044,17 @@ export async function updateNpmInstalledPlugins(params: {
10261044
});
10271045
continue;
10281046
}
1029-
if (
1030-
!probe.ok &&
1031-
record.source === "npm" &&
1032-
npmSpecs?.fallbackSpec &&
1033-
shouldFallbackBetaNpmUpdate(probe)
1034-
) {
1047+
let usedNpmFallback = false;
1048+
if (!probe.ok && record.source === "npm" && npmSpecs?.fallbackSpec) {
10351049
logger.warn?.(
1036-
`Plugin "${pluginId}" has no beta npm release for ${npmSpecs.fallbackLabel ?? effectiveSpec}; falling back to ${npmSpecs.fallbackSpec}.`,
1050+
describeBetaNpmFallback({
1051+
pluginId,
1052+
betaSpec: npmSpecs.fallbackLabel ?? effectiveSpec,
1053+
fallbackSpec: npmSpecs.fallbackSpec,
1054+
result: probe,
1055+
}),
10371056
);
1057+
usedNpmFallback = true;
10381058
probe = await installPluginFromNpmSpec({
10391059
spec: npmSpecs.fallbackSpec,
10401060
mode: "update",
@@ -1083,7 +1103,11 @@ export async function updateNpmInstalledPlugins(params: {
10831103
record.source === "npm"
10841104
? formatNpmInstallFailure({
10851105
pluginId,
1086-
spec: effectiveSpec!,
1106+
spec: npmUpdateFailureSpec({
1107+
effectiveSpec,
1108+
fallbackSpec: npmSpecs?.fallbackSpec,
1109+
usedFallback: usedNpmFallback,
1110+
}),
10871111
phase: "check",
10881112
result: probe,
10891113
})
@@ -1207,15 +1231,17 @@ export async function updateNpmInstalledPlugins(params: {
12071231
});
12081232
continue;
12091233
}
1210-
if (
1211-
!result.ok &&
1212-
record.source === "npm" &&
1213-
npmSpecs?.fallbackSpec &&
1214-
shouldFallbackBetaNpmUpdate(result)
1215-
) {
1234+
let usedNpmFallback = false;
1235+
if (!result.ok && record.source === "npm" && npmSpecs?.fallbackSpec) {
12161236
logger.warn?.(
1217-
`Plugin "${pluginId}" has no beta npm release for ${npmSpecs.fallbackLabel ?? effectiveSpec}; falling back to ${npmSpecs.fallbackSpec}.`,
1237+
describeBetaNpmFallback({
1238+
pluginId,
1239+
betaSpec: npmSpecs.fallbackLabel ?? effectiveSpec,
1240+
fallbackSpec: npmSpecs.fallbackSpec,
1241+
result,
1242+
}),
12181243
);
1244+
usedNpmFallback = true;
12191245
result = await installPluginFromNpmSpec({
12201246
spec: npmSpecs.fallbackSpec,
12211247
mode: "update",
@@ -1262,7 +1288,11 @@ export async function updateNpmInstalledPlugins(params: {
12621288
record.source === "npm"
12631289
? formatNpmInstallFailure({
12641290
pluginId,
1265-
spec: effectiveSpec!,
1291+
spec: npmUpdateFailureSpec({
1292+
effectiveSpec,
1293+
fallbackSpec: npmSpecs?.fallbackSpec,
1294+
usedFallback: usedNpmFallback,
1295+
}),
12661296
phase: "update",
12671297
result: result,
12681298
})

0 commit comments

Comments
 (0)