From c96a12d3c887465437285e530c759a1c9c3e82d3 Mon Sep 17 00:00:00 2001 From: "B.K." Date: Thu, 4 Jun 2026 02:00:40 +0300 Subject: [PATCH] fix(update): surface plugin channel fallbacks (#81422) * fix: surface plugin update channel fallbacks * fix: clarify dry-run plugin fallback output * fix: preserve failed plugin fallback metadata * chore: mark compatibility aliases deprecated * chore: fix channel runtime lint directive --------- Co-authored-by: Peter Steinberger --- .../run/preemptive-compaction.ts | 6 +- src/cli/plugins-update-outcomes.ts | 12 +++ src/cli/update-cli.test.ts | 43 ++++++++ src/cli/update-cli/update-command.ts | 18 ++++ src/infra/channel-runtime-context.ts | 2 +- src/infra/home-dir.ts | 6 +- src/infra/update-runner.ts | 8 ++ src/media/audio.ts | 6 +- src/plugins/update.test.ts | 101 ++++++++++++++++++ src/plugins/update.ts | 89 +++++++++++++-- 10 files changed, 281 insertions(+), 10 deletions(-) diff --git a/src/agents/embedded-agent-runner/run/preemptive-compaction.ts b/src/agents/embedded-agent-runner/run/preemptive-compaction.ts index 6b3176cd54ba..6e468876b5a3 100644 --- a/src/agents/embedded-agent-runner/run/preemptive-compaction.ts +++ b/src/agents/embedded-agent-runner/run/preemptive-compaction.ts @@ -223,7 +223,11 @@ export function estimateRenderedLlmBoundaryTokenPressure(params: { return Math.max(0, Math.ceil((systemTokens + promptTokens) * SAFETY_MARGIN)); } -/** Backward-compatible alias for callers that still name this a pre-prompt estimate. */ +/** + * Backward-compatible alias for callers that still name this a pre-prompt estimate. + * + * @deprecated Use estimateLlmBoundaryTokenPressure. + */ export function estimatePrePromptTokens(params: { messages: AgentMessage[]; systemPrompt?: string; diff --git a/src/cli/plugins-update-outcomes.ts b/src/cli/plugins-update-outcomes.ts index b4341364b464..818b7f0967f9 100644 --- a/src/cli/plugins-update-outcomes.ts +++ b/src/cli/plugins-update-outcomes.ts @@ -4,6 +4,9 @@ import { theme } from "../../packages/terminal-core/src/theme.js"; type PluginUpdateCliOutcome = { status: string; message: string; + channelFallback?: { + message: string; + }; }; /** Log update outcomes with severity styling and report whether any errors occurred. */ @@ -16,13 +19,22 @@ export function logPluginUpdateOutcomes(params: { if (outcome.status === "error") { hasErrors = true; params.log(theme.error(outcome.message)); + if (outcome.channelFallback) { + params.log(theme.warn(outcome.channelFallback.message)); + } continue; } if (outcome.status === "skipped") { params.log(theme.warn(outcome.message)); + if (outcome.channelFallback) { + params.log(theme.warn(outcome.channelFallback.message)); + } continue; } params.log(outcome.message); + if (outcome.channelFallback) { + params.log(theme.warn(outcome.channelFallback.message)); + } } return { hasErrors }; } diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index cd231ac7d6d1..72c7e3cc923c 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -1580,6 +1580,49 @@ describe("update-cli", () => { expect(npmPluginUpdateCall()?.timeoutMs).toBe(1_800_000); }); + it("prints plugin channel fallbacks near the post-core plugin summary", async () => { + updateNpmInstalledPlugins.mockResolvedValueOnce({ + changed: false, + config: baseConfig, + outcomes: [ + { + pluginId: "lossless-claw", + status: "updated", + message: "Updated lossless-claw: 1.0.0 -> 1.0.1.", + channelFallback: { + requestedSpec: "lossless-claw@beta", + usedSpec: "lossless-claw", + requestedLabel: "@beta", + usedLabel: "@latest", + reason: "unavailable", + message: + "plugin channel fallback: lossless-claw used @latest because @beta was unavailable", + }, + }, + ], + }); + + await withEnvAsync( + { + OPENCLAW_UPDATE_POST_CORE: "1", + OPENCLAW_UPDATE_POST_CORE_CHANNEL: "beta", + }, + async () => { + await updateCommand({ restart: false }); + }, + ); + + const logs = vi.mocked(runtimeCapture.log).mock.calls.map((call) => String(call[0])); + expect(logs.some((line) => line.includes("npm plugins: 1 updated, 0 unchanged."))).toBe(true); + expect( + logs.some((line) => + line.includes( + "plugin channel fallback: lossless-claw used @latest because @beta was unavailable", + ), + ), + ).toBe(true); + }); + it("uses a fail-closed integrity policy for post-core plugin updates", async () => { await withEnvAsync( { diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 13c7f5ea7cd6..340ef99f8171 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -560,6 +560,20 @@ function createGuidedPostUpdatePluginOutcome(outcome: PluginUpdateOutcome): { }; } +function collectPluginChannelFallbackMessages(outcomes: readonly PluginUpdateOutcome[]): string[] { + const seen = new Set(); + const messages: string[] = []; + for (const outcome of outcomes) { + const message = outcome.channelFallback?.message; + if (!message || seen.has(message)) { + continue; + } + seen.add(message); + messages.push(message); + } + return messages; +} + function isDisabledAfterFailureOutcome(outcome: PluginUpdateOutcome): boolean { return outcome.status === "skipped" && outcome.message.includes("after plugin update failure"); } @@ -1923,6 +1937,10 @@ export async function updatePluginsAfterCoreUpdate(params: { defaultRuntime.log(theme.muted(`npm plugins: ${parts.join(", ")}.`)); } + for (const message of collectPluginChannelFallbackMessages(pluginUpdateOutcomes)) { + defaultRuntime.log(theme.warn(message)); + } + for (const outcome of pluginUpdateOutcomes) { if (outcome.status !== "error") { continue; diff --git a/src/infra/channel-runtime-context.ts b/src/infra/channel-runtime-context.ts index f3e9e1b45b48..3893fda3cfac 100644 --- a/src/infra/channel-runtime-context.ts +++ b/src/infra/channel-runtime-context.ts @@ -49,8 +49,8 @@ export function registerChannelRuntimeContext( }); } -// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Runtime context values are caller-typed by key. /** Reads a channel-scoped runtime context from the current runtime registry. */ +// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Runtime context values are caller-typed by key. export function getChannelRuntimeContext( params: ChannelRuntimeContextKey & { channelRuntime?: ChannelRuntimeSurface; diff --git a/src/infra/home-dir.ts b/src/infra/home-dir.ts index ad766a3752ea..375bddafb402 100644 --- a/src/infra/home-dir.ts +++ b/src/infra/home-dir.ts @@ -129,7 +129,11 @@ export function resolveHomeRelativePath( return path.resolve(trimmed); } -/** Backward-compatible alias for resolving user paths against the effective home. */ +/** + * Backward-compatible alias for resolving user paths against the effective home. + * + * @deprecated Use resolveHomeRelativePath. + */ export function resolveUserPath( input: string, env: NodeJS.ProcessEnv = process.env, diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index bbf0beee5777..62fc8c9edcb3 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -86,6 +86,14 @@ export type UpdateRunResult = { message: string; currentVersion?: string; nextVersion?: string; + channelFallback?: { + requestedSpec: string; + usedSpec: string; + requestedLabel: string; + usedLabel: string; + reason: "unavailable" | "failed"; + message: string; + }; }>; }; integrityDrifts: Array<{ diff --git a/src/media/audio.ts b/src/media/audio.ts index 72dda1815d42..e4880b6ab1f8 100644 --- a/src/media/audio.ts +++ b/src/media/audio.ts @@ -35,7 +35,11 @@ export function isVoiceMessageCompatibleAudio(opts: { return VOICE_MESSAGE_AUDIO_EXTENSIONS.has(ext); } -/** Backward-compatible alias for voice-message audio compatibility checks. */ +/** + * Backward-compatible alias for voice-message audio compatibility checks. + * + * @deprecated Use isVoiceMessageCompatibleAudio. + */ export function isVoiceCompatibleAudio(opts: { contentType?: string | null; fileName?: string | null; diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 92d077b96f08..88f5603c9c49 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -2188,6 +2188,52 @@ describe("updateNpmInstalledPlugins", () => { expect(result.outcomes[0]?.message).toBe( "Updated openclaw-codex-app-server: unknown -> 0.2.6. (warning: beta channel fallback used openclaw-codex-app-server because openclaw-codex-app-server@beta could not be used).", ); + expect(result.outcomes[0]?.channelFallback).toEqual({ + requestedSpec: "openclaw-codex-app-server@beta", + usedSpec: "openclaw-codex-app-server", + requestedLabel: "@beta", + usedLabel: "@latest", + reason: "unavailable", + message: + "plugin channel fallback: openclaw-codex-app-server used @latest because @beta was unavailable", + }); + }); + + it("reports npm beta fallback as tentative during dry-run checks", async () => { + installPluginFromNpmSpecMock + .mockResolvedValueOnce({ + ok: false, + error: + "npm ERR! code ETARGET\nnpm ERR! No matching version found for openclaw-codex-app-server@beta.", + }) + .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 result = await updateNpmInstalledPlugins({ + config: createCodexAppServerInstallConfig({ + spec: "openclaw-codex-app-server", + }), + pluginIds: ["openclaw-codex-app-server"], + updateChannel: "beta", + dryRun: true, + }); + + expect(result.outcomes[0]?.message).toBe( + "Would update openclaw-codex-app-server: unknown -> 0.2.6. (warning: beta channel fallback would use openclaw-codex-app-server because openclaw-codex-app-server@beta could not be used).", + ); + expect(result.outcomes[0]?.channelFallback?.message).toBe( + "plugin channel fallback: openclaw-codex-app-server would use @latest because @beta was unavailable", + ); }); it("falls back to the default npm spec when the beta package exists but is invalid", async () => { @@ -2233,6 +2279,12 @@ describe("updateNpmInstalledPlugins", () => { expect(result.outcomes[0]?.message).toBe( "Updated openclaw-codex-app-server: unknown -> 0.2.6. (warning: beta channel fallback used openclaw-codex-app-server because openclaw-codex-app-server@beta could not be used).", ); + expect(result.outcomes[0]?.channelFallback).toMatchObject({ + requestedLabel: "@beta", + usedLabel: "@latest", + reason: "failed", + message: "plugin channel fallback: openclaw-codex-app-server used @latest after @beta failed", + }); }); it("reports the fallback npm spec when beta fallback also fails", async () => { @@ -2262,6 +2314,55 @@ describe("updateNpmInstalledPlugins", () => { status: "error", message: "Failed to update openclaw-codex-app-server: npm package not found for openclaw-codex-app-server.", + channelFallback: { + requestedSpec: "openclaw-codex-app-server@beta", + usedSpec: "openclaw-codex-app-server", + requestedLabel: "@beta", + usedLabel: "@latest", + reason: "failed", + message: + "plugin channel fallback: openclaw-codex-app-server used @latest after @beta failed", + }, + }, + ]); + }); + + it("keeps fallback metadata when a dry-run 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", + dryRun: true, + }); + + expect(result.outcomes).toEqual([ + { + pluginId: "openclaw-codex-app-server", + status: "error", + message: + "Failed to check openclaw-codex-app-server: npm package not found for openclaw-codex-app-server.", + channelFallback: { + requestedSpec: "openclaw-codex-app-server@beta", + usedSpec: "openclaw-codex-app-server", + requestedLabel: "@beta", + usedLabel: "@latest", + reason: "failed", + message: + "plugin channel fallback: openclaw-codex-app-server would use @latest after @beta failed", + }, }, ]); }); diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 2c0f14a7aac4..58f6ec6d51d1 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -68,12 +68,22 @@ export type PluginUpdateLogger = { export type PluginUpdateStatus = "updated" | "unchanged" | "skipped" | "error"; +export type PluginUpdateChannelFallback = { + requestedSpec: string; + usedSpec: string; + requestedLabel: string; + usedLabel: string; + reason: "unavailable" | "failed"; + message: string; +}; + export type PluginUpdateOutcome = { pluginId: string; status: PluginUpdateStatus; message: string; currentVersion?: string; nextVersion?: string; + channelFallback?: PluginUpdateChannelFallback; }; export type PluginUpdateSummary = { @@ -485,6 +495,13 @@ function shouldFallbackBetaClawHubUpdate(result: { ok: false; code?: string }): return shouldFallbackClawHubToDefault(result); } +function isUnavailableNpmTarget(result: { ok: false; code?: string; error: string }): boolean { + return ( + 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(result.error) + ); +} + function describeBetaNpmFallback(params: { pluginId: string; betaSpec: string | undefined; @@ -492,15 +509,47 @@ function describeBetaNpmFallback(params: { 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 missingBeta = isUnavailableNpmTarget(params.result); const reason = missingBeta ? "has no beta npm release" : "failed beta npm update"; return `Plugin "${params.pluginId}" ${reason} for ${betaSpec}; using ${params.fallbackSpec} instead. Core update can still complete.`; } +function formatNpmSpecSelectorLabel(spec: string | undefined): string { + const parsed = spec ? parseRegistryNpmSpec(spec) : undefined; + if (!parsed) { + return spec ?? "unknown"; + } + if (parsed.selectorKind === "none") { + return "@latest"; + } + return `@${parsed.selector}`; +} + +function describeNpmChannelFallback(params: { + pluginId: string; + requestedSpec: string | undefined; + usedSpec: string; + result: { ok: false; code?: string; error: string }; + verb: "used" | "would use"; +}): PluginUpdateChannelFallback { + const requestedSpec = params.requestedSpec ?? "unknown"; + const requestedLabel = formatNpmSpecSelectorLabel(params.requestedSpec); + const usedLabel = formatNpmSpecSelectorLabel(params.usedSpec); + const reason = isUnavailableNpmTarget(params.result) ? "unavailable" : "failed"; + const message = + reason === "unavailable" + ? `plugin channel fallback: ${params.pluginId} ${params.verb} ${usedLabel} because ${requestedLabel} was unavailable` + : `plugin channel fallback: ${params.pluginId} ${params.verb} ${usedLabel} after ${requestedLabel} failed`; + return { + requestedSpec, + usedSpec: params.usedSpec, + requestedLabel, + usedLabel, + reason, + message, + }; +} + function formatBetaChannelFallbackOutcomeSuffix(params: { fallbackLabel: string | undefined; fallbackSpec: string | undefined; @@ -990,7 +1039,11 @@ export async function updateNpmInstalledPlugins(params: { return await installPluginFromNpmSpec(installParams); }; - const recordFailure = (pluginId: string, message: string) => { + const recordFailure = ( + pluginId: string, + message: string, + channelFallback?: PluginUpdateChannelFallback, + ) => { if (params.disableOnFailure && !params.dryRun) { const disabledMessage = `Disabled "${pluginId}" after plugin update failure; OpenClaw will continue without it. ` + @@ -1002,6 +1055,7 @@ export async function updateNpmInstalledPlugins(params: { pluginId, status: "skipped", message: disabledMessage, + ...(channelFallback ? { channelFallback } : {}), }); return; } @@ -1009,6 +1063,7 @@ export async function updateNpmInstalledPlugins(params: { pluginId, status: "error", message, + ...(channelFallback ? { channelFallback } : {}), }); }; @@ -1317,6 +1372,7 @@ export async function updateNpmInstalledPlugins(params: { let usedNpmFallback = false; let usedOfficialNpmFallback = false; let channelFallbackSuffix = ""; + let npmChannelFallback: PluginUpdateChannelFallback | undefined; if (!probe.ok && record.source === "npm" && npmSpecs?.fallbackSpec) { logger.warn?.( describeBetaNpmFallback({ @@ -1327,6 +1383,13 @@ export async function updateNpmInstalledPlugins(params: { }), ); usedNpmFallback = true; + npmChannelFallback = describeNpmChannelFallback({ + pluginId, + requestedSpec: npmSpecs.fallbackLabel ?? effectiveSpec, + usedSpec: npmSpecs.fallbackSpec, + result: probe, + verb: "would use", + }); channelFallbackSuffix = formatBetaChannelFallbackOutcomeSuffix({ fallbackLabel: npmSpecs.fallbackLabel ?? effectiveSpec, fallbackSpec: npmSpecs.fallbackSpec, @@ -1448,6 +1511,7 @@ export async function updateNpmInstalledPlugins(params: { phase: "check", error: probe.error, }), + npmChannelFallback, ); continue; } @@ -1485,6 +1549,7 @@ export async function updateNpmInstalledPlugins(params: { currentVersion: currentVersion ?? undefined, nextVersion: resolvedProbeVersion, message: `${pluginId} is up to date (${currentLabel}).${channelFallbackSuffix}`, + ...(npmChannelFallback ? { channelFallback: npmChannelFallback } : {}), }); } else { outcomes.push({ @@ -1493,6 +1558,7 @@ export async function updateNpmInstalledPlugins(params: { currentVersion: currentVersion ?? undefined, nextVersion: resolvedProbeVersion, message: `Would update ${pluginId}: ${currentLabel} -> ${nextVersion}.${channelFallbackSuffix}`, + ...(npmChannelFallback ? { channelFallback: npmChannelFallback } : {}), }); } continue; @@ -1567,6 +1633,7 @@ export async function updateNpmInstalledPlugins(params: { let channelFallbackSuffix = ""; let resultSource = record.source; activeClawHubInstallSpec = effectiveSpec; + let npmChannelFallback: PluginUpdateChannelFallback | undefined; if (!result.ok && record.source === "npm" && npmSpecs?.fallbackSpec) { logger.warn?.( describeBetaNpmFallback({ @@ -1577,6 +1644,13 @@ export async function updateNpmInstalledPlugins(params: { }), ); usedNpmFallback = true; + npmChannelFallback = describeNpmChannelFallback({ + pluginId, + requestedSpec: npmSpecs.fallbackLabel ?? effectiveSpec, + usedSpec: npmSpecs.fallbackSpec, + result, + verb: "used", + }); channelFallbackSuffix = formatBetaChannelFallbackOutcomeSuffix({ fallbackLabel: npmSpecs.fallbackLabel ?? effectiveSpec, fallbackSpec: npmSpecs.fallbackSpec, @@ -1696,6 +1770,7 @@ export async function updateNpmInstalledPlugins(params: { phase: "update", error: result.error, }), + npmChannelFallback, ); continue; } @@ -1776,6 +1851,7 @@ export async function updateNpmInstalledPlugins(params: { currentVersion: currentVersion ?? undefined, nextVersion: nextVersion ?? undefined, message: `${pluginId} already at ${currentLabel}.${channelFallbackSuffix}`, + ...(npmChannelFallback ? { channelFallback: npmChannelFallback } : {}), }); } else { outcomes.push({ @@ -1784,6 +1860,7 @@ export async function updateNpmInstalledPlugins(params: { currentVersion: currentVersion ?? undefined, nextVersion: nextVersion ?? undefined, message: `Updated ${pluginId}: ${currentLabel} -> ${nextLabel}.${channelFallbackSuffix}`, + ...(npmChannelFallback ? { channelFallback: npmChannelFallback } : {}), }); } }