From 2014c2327b20ffeb1c91658ba1491c659bc97bfd Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 5 May 2026 17:27:32 -0700 Subject: [PATCH] fix(plugins): sync official plugin installs during update (#78065) * fix(plugins): sync official npm installs during update * fix(plugins): sync official clawhub installs during update * test(update): mock official plugin sync helpers --------- Co-authored-by: Patrick Erichsen --- CHANGELOG.md | 1 + src/cli/update-cli.test.ts | 5 +- src/cli/update-cli/update-command.test.ts | 78 +++++++++ src/cli/update-cli/update-command.ts | 30 +++- src/plugins/update.test.ts | 201 ++++++++++++++++++++++ src/plugins/update.ts | 82 +++++++-- 6 files changed, 378 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5b301fbfbc..4366f7d8633 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,7 @@ Docs: https://docs.openclaw.ai - Control UI/sessions: show each session's agent runtime in the Sessions table and allow filtering by runtime labels, matching the Agents panel runtime wording. Thanks @vincentkoc. - Discord/streaming: show live reasoning text in progress drafts instead of a bare `Reasoning` status line. - Gateway/status: avoid marking fast repeated health/status samples as event-loop degraded from CPU/utilization alone until the Gateway has accumulated a sustained sampling window. Thanks @shakkernerd. +- Plugins/update: keep installed official npm and ClawHub plugins such as Codex, Discord, WhatsApp, and diagnostics plugins synced during host updates even when disabled or previously exact-pinned, while preserving third-party plugin pins. Thanks @vincentkoc. - Doctor/status: warn when `OPENCLAW_GATEWAY_TOKEN` would shadow a different active `gateway.auth.token` source for local CLI commands, while avoiding false positives when config points at the same env token. Fixes #74271. Thanks @yelog. - Gateway/HTTP: avoid loading managed outgoing-image media handlers for unrelated requests, so disabled OpenAI-compatible routes return 404 without waiting on lazy media sidecars. Thanks @vincentkoc. - Gateway/OpenAI-compatible: send the assistant role SSE chunk as soon as streaming chat-completion headers are accepted, so cold agent setup cannot leave `/v1/chat/completions` clients with a bodyless 200 response until their idle timeout fires. diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index add4d9be1f9..6e1fae3a5bb 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -168,6 +168,8 @@ vi.mock("../utils.js", async (importOriginal) => { }); vi.mock("../plugins/update.js", () => ({ + resolveTrustedSourceLinkedOfficialClawHubSpec: vi.fn(() => undefined), + resolveTrustedSourceLinkedOfficialNpmSpec: vi.fn(() => undefined), syncPluginsForUpdateChannel: (...args: unknown[]) => syncPluginsForUpdateChannel(...args), updateNpmInstalledPlugins: (...args: unknown[]) => updateNpmInstalledPlugins(...args), })); @@ -2439,13 +2441,14 @@ describe("update-cli", () => { | OpenClawConfig | undefined; const updateCall = vi.mocked(updateNpmInstalledPlugins).mock.calls[0]?.[0] as - | { skipDisabledPlugins?: boolean } + | { skipDisabledPlugins?: boolean; syncOfficialPluginInstalls?: boolean } | undefined; expect(syncConfig?.plugins?.installs).toEqual(pluginInstallRecords); expect(syncConfig?.update?.channel).toBe("beta"); expect(syncConfig?.gateway?.auth).toBeUndefined(); expect(syncConfig?.plugins?.entries).toBeUndefined(); expect(updateCall?.skipDisabledPlugins).toBe(true); + expect(updateCall?.syncOfficialPluginInstalls).toBe(true); }); it("persists channel and runs post-update work after switching from package to git", async () => { diff --git a/src/cli/update-cli/update-command.test.ts b/src/cli/update-cli/update-command.test.ts index 22d0a81333c..781b7a62641 100644 --- a/src/cli/update-cli/update-command.test.ts +++ b/src/cli/update-cli/update-command.test.ts @@ -253,6 +253,84 @@ describe("collectMissingPluginInstallPayloads", () => { await fs.rm(tmpDir, { recursive: true, force: true }); } }); + + it("keeps disabled trusted official npm records eligible for payload repair when requested", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-plugin-payload-")); + const missingDir = path.join(tmpDir, "state", "npm", "node_modules", "@openclaw", "codex"); + try { + await expect( + collectMissingPluginInstallPayloads({ + env: { HOME: tmpDir } as NodeJS.ProcessEnv, + skipDisabledPlugins: true, + syncOfficialPluginInstalls: true, + config: { + plugins: { + entries: { + codex: { + enabled: false, + }, + }, + }, + }, + records: { + codex: { + source: "npm", + spec: "@openclaw/codex@2026.5.3", + resolvedName: "@openclaw/codex", + resolvedSpec: "@openclaw/codex@2026.5.3", + installPath: missingDir, + }, + }, + }), + ).resolves.toEqual([ + { + pluginId: "codex", + installPath: missingDir, + reason: "missing-package-dir", + }, + ]); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("keeps disabled trusted official ClawHub records eligible for payload repair when requested", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-plugin-payload-")); + const missingDir = path.join(tmpDir, "state", "clawhub", "diagnostics-otel"); + try { + await expect( + collectMissingPluginInstallPayloads({ + env: { HOME: tmpDir } as NodeJS.ProcessEnv, + skipDisabledPlugins: true, + syncOfficialPluginInstalls: true, + config: { + plugins: { + entries: { + "diagnostics-otel": { + enabled: false, + }, + }, + }, + }, + records: { + "diagnostics-otel": { + source: "clawhub", + spec: "clawhub:@openclaw/diagnostics-otel@2026.5.3", + installPath: missingDir, + }, + }, + }), + ).resolves.toEqual([ + { + pluginId: "diagnostics-otel", + installPath: missingDir, + reason: "missing-package-dir", + }, + ]); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); }); describe("shouldUseLegacyProcessRestartAfterUpdate", () => { diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 1cd1e038069..535b00d5f6d 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -58,6 +58,8 @@ import { withPluginInstallRecords, } from "../../plugins/installed-plugin-index-records.js"; import { + resolveTrustedSourceLinkedOfficialClawHubSpec, + resolveTrustedSourceLinkedOfficialNpmSpec, syncPluginsForUpdateChannel, updateNpmInstalledPlugins, type PluginUpdateIntegrityDriftParams, @@ -190,6 +192,7 @@ export async function collectMissingPluginInstallPayloads(params: { records: Record; config?: OpenClawConfig; skipDisabledPlugins?: boolean; + syncOfficialPluginInstalls?: boolean; env?: NodeJS.ProcessEnv; }): Promise { const env = params.env ?? process.env; @@ -204,6 +207,12 @@ export async function collectMissingPluginInstallPayloads(params: { if (!isTrackedPackageInstallRecord(record)) { continue; } + const officialNpmSpec = params.syncOfficialPluginInstalls + ? resolveTrustedSourceLinkedOfficialNpmSpec({ pluginId, record }) + : undefined; + const officialClawHubSpec = params.syncOfficialPluginInstalls + ? resolveTrustedSourceLinkedOfficialClawHubSpec({ pluginId, record }) + : undefined; if (normalizedPluginConfig && params.config) { const enableState = resolveEffectiveEnableState({ id: pluginId, @@ -211,7 +220,7 @@ export async function collectMissingPluginInstallPayloads(params: { config: normalizedPluginConfig, rootConfig: params.config, }); - if (!enableState.enabled) { + if (!enableState.enabled && !officialNpmSpec && !officialClawHubSpec) { continue; } } @@ -1168,6 +1177,7 @@ async function updatePluginsAfterCoreUpdate(params: { records, config: pluginConfig, skipDisabledPlugins: true, + syncOfficialPluginInstalls: true, }); if (missing.length === 0) { return []; @@ -1188,6 +1198,21 @@ async function updatePluginsAfterCoreUpdate(params: { defaultRuntime.log(theme.warn(warning.message)); } } + const repairResult = await updateNpmInstalledPlugins({ + config: pluginConfig, + pluginIds: missingIds, + timeoutMs: params.timeoutMs, + updateChannel: params.channel, + skipDisabledPlugins: true, + syncOfficialPluginInstalls: true, + disableOnFailure: true, + logger: pluginLogger, + onIntegrityDrift: onPluginIntegrityDrift, + }); + pluginConfig = repairResult.config; + pluginsChanged ||= repairResult.changed; + npmPluginsChanged ||= repairResult.changed; + pluginUpdateOutcomes.push(...repairResult.outcomes); return missingIds; }; @@ -1199,6 +1224,8 @@ async function updatePluginsAfterCoreUpdate(params: { updateChannel: params.channel, skipIds: new Set([...syncResult.summary.switchedToNpm, ...missingPayloadIds]), skipDisabledPlugins: true, + syncOfficialPluginInstalls: true, + disableOnFailure: true, logger: pluginLogger, onIntegrityDrift: onPluginIntegrityDrift, }); @@ -1217,6 +1244,7 @@ async function updatePluginsAfterCoreUpdate(params: { records: pluginConfig.plugins?.installs ?? {}, config: pluginConfig, skipDisabledPlugins: true, + syncOfficialPluginInstalls: true, }); pluginUpdateOutcomes.push( ...remainingMissingPayloads diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 2f1dc104abb..8a26cb634df 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -1111,6 +1111,207 @@ describe("updateNpmInstalledPlugins", () => { ]); }); + it("updates disabled trusted official npm installs from the channel spec when requested", async () => { + const installPath = createInstalledPackageDir({ + name: "@openclaw/codex", + version: "2026.5.3", + }); + mockNpmViewMetadata({ + name: "@openclaw/codex", + version: "2026.5.4", + integrity: "sha512-next", + shasum: "next", + }); + installPluginFromNpmSpecMock.mockResolvedValue( + createSuccessfulNpmUpdateResult({ + pluginId: "codex", + targetDir: installPath, + version: "2026.5.4", + npmResolution: { + name: "@openclaw/codex", + version: "2026.5.4", + resolvedSpec: "@openclaw/codex@2026.5.4", + }, + }), + ); + + const result = await updateNpmInstalledPlugins({ + config: { + plugins: { + entries: { + codex: { + enabled: false, + config: { preserved: true }, + }, + }, + installs: { + codex: { + source: "npm", + spec: "@openclaw/codex@2026.5.3", + installPath, + }, + }, + }, + }, + skipDisabledPlugins: true, + syncOfficialPluginInstalls: true, + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@openclaw/codex", + expectedPluginId: "codex", + trustedSourceLinkedOfficialInstall: true, + }), + ); + expect(result.changed).toBe(true); + expect(result.config.plugins?.entries?.codex).toEqual({ + enabled: false, + config: { preserved: true }, + }); + expect(result.config.plugins?.installs?.codex).toMatchObject({ + source: "npm", + spec: "@openclaw/codex", + version: "2026.5.4", + resolvedName: "@openclaw/codex", + resolvedVersion: "2026.5.4", + resolvedSpec: "@openclaw/codex@2026.5.4", + }); + expect(result.outcomes[0]).toMatchObject({ + pluginId: "codex", + status: "updated", + currentVersion: "2026.5.3", + nextVersion: "2026.5.4", + }); + }); + + it("keeps third-party exact pinned npm specs pinned during official install sync", async () => { + const installPath = createInstalledPackageDir({ + name: "@acme/demo", + version: "1.2.3", + }); + installPluginFromNpmSpecMock.mockResolvedValue( + createSuccessfulNpmUpdateResult({ + pluginId: "demo", + targetDir: installPath, + version: "1.2.3", + }), + ); + + await updateNpmInstalledPlugins({ + config: createNpmInstallConfig({ + pluginId: "demo", + spec: "@acme/demo@1.2.3", + installPath, + }), + pluginIds: ["demo"], + dryRun: true, + syncOfficialPluginInstalls: true, + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@acme/demo@1.2.3", + expectedPluginId: "demo", + }), + ); + }); + + it("updates disabled trusted official ClawHub installs through the catalog spec", async () => { + installPluginFromClawHubMock.mockResolvedValue( + createSuccessfulClawHubUpdateResult({ + pluginId: "diagnostics-otel", + targetDir: "/tmp/diagnostics-otel", + version: "2026.5.4", + clawhubPackage: "@openclaw/diagnostics-otel", + }), + ); + + const config = createClawHubInstallConfig({ + pluginId: "diagnostics-otel", + installPath: "/tmp/diagnostics-otel", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "@openclaw/diagnostics-otel", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + spec: "clawhub:@openclaw/diagnostics-otel@2026.5.3", + }); + const result = await updateNpmInstalledPlugins({ + config: { + ...config, + plugins: { + ...config.plugins, + entries: { + "diagnostics-otel": { + enabled: false, + config: { preserved: true }, + }, + }, + }, + }, + skipDisabledPlugins: true, + syncOfficialPluginInstalls: true, + }); + + expect(installPluginFromClawHubMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "clawhub:@openclaw/diagnostics-otel", + expectedPluginId: "diagnostics-otel", + }), + ); + expect(result.config.plugins?.installs?.["diagnostics-otel"]).toMatchObject({ + source: "clawhub", + spec: "clawhub:@openclaw/diagnostics-otel", + version: "2026.5.4", + clawhubPackage: "@openclaw/diagnostics-otel", + clawhubChannel: "official", + }); + expect(result.config.plugins?.entries?.["diagnostics-otel"]).toEqual({ + enabled: false, + config: { preserved: true }, + }); + }); + + it("updates bare trusted official ClawHub installs through the catalog spec", async () => { + installPluginFromClawHubMock.mockResolvedValue( + createSuccessfulClawHubUpdateResult({ + pluginId: "diagnostics-prometheus", + targetDir: "/tmp/diagnostics-prometheus", + version: "2026.5.4", + clawhubPackage: "@openclaw/diagnostics-prometheus", + }), + ); + + const result = await updateNpmInstalledPlugins({ + config: { + plugins: { + installs: { + "diagnostics-prometheus": { + source: "clawhub", + spec: "clawhub:@openclaw/diagnostics-prometheus@2026.5.3", + installPath: "/tmp/diagnostics-prometheus", + }, + }, + }, + }, + syncOfficialPluginInstalls: true, + }); + + expect(installPluginFromClawHubMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "clawhub:@openclaw/diagnostics-prometheus", + expectedPluginId: "diagnostics-prometheus", + }), + ); + expect(result.config.plugins?.installs?.["diagnostics-prometheus"]).toMatchObject({ + source: "clawhub", + spec: "clawhub:@openclaw/diagnostics-prometheus", + version: "2026.5.4", + clawhubPackage: "@openclaw/diagnostics-prometheus", + clawhubChannel: "official", + }); + }); + it("keeps enabled tracked plugin update failures fatal when disabled skipping is enabled", async () => { installPluginFromNpmSpecMock.mockResolvedValue({ ok: false, diff --git a/src/plugins/update.ts b/src/plugins/update.ts index d1b36e0221e..1a49347d055 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -467,31 +467,66 @@ function resolveNpmSpecPackageName(spec: string | undefined): string | undefined return spec ? parseRegistryNpmSpec(spec)?.name : undefined; } -function isTrustedSourceLinkedOfficialNpmUpdate(params: { +function resolveClawHubSpecPackageName(spec: string | undefined): string | undefined { + return spec ? parseClawHubPluginSpec(spec)?.name : undefined; +} + +export function resolveTrustedSourceLinkedOfficialNpmSpec(params: { pluginId: string; - spec: string | undefined; record: PluginInstallRecord; -}): boolean { +}): string | undefined { if (params.record.source !== "npm") { - return false; + return undefined; } const entry = getOfficialExternalPluginCatalogEntry(params.pluginId); if (!entry) { - return false; + return undefined; } - const officialPackageName = resolveNpmSpecPackageName( - resolveOfficialExternalPluginInstall(entry)?.npmSpec, - ); - const requestedPackageName = resolveNpmSpecPackageName(params.spec); - if (!officialPackageName || requestedPackageName !== officialPackageName) { - return false; + const officialSpec = resolveOfficialExternalPluginInstall(entry)?.npmSpec; + const officialPackageName = resolveNpmSpecPackageName(officialSpec); + if (!officialSpec || !officialPackageName) { + return undefined; } const recordedPackageNames = [ params.record.resolvedName, resolveNpmSpecPackageName(params.record.spec), resolveNpmSpecPackageName(params.record.resolvedSpec), ].filter((value): value is string => Boolean(value)); - return recordedPackageNames.includes(officialPackageName); + return recordedPackageNames.includes(officialPackageName) ? officialSpec : undefined; +} + +export function resolveTrustedSourceLinkedOfficialClawHubSpec(params: { + pluginId: string; + record: PluginInstallRecord; +}): string | undefined { + if (params.record.source !== "clawhub") { + return undefined; + } + const entry = getOfficialExternalPluginCatalogEntry(params.pluginId); + if (!entry) { + return undefined; + } + const officialSpec = resolveOfficialExternalPluginInstall(entry)?.clawhubSpec; + const officialPackageName = resolveClawHubSpecPackageName(officialSpec); + if (!officialSpec || !officialPackageName) { + return undefined; + } + const recordedPackageNames = [ + params.record.clawhubPackage, + resolveClawHubSpecPackageName(params.record.spec), + ].filter((value): value is string => Boolean(value)); + return recordedPackageNames.includes(officialPackageName) ? officialSpec : undefined; +} + +function isTrustedSourceLinkedOfficialNpmUpdate(params: { + pluginId: string; + spec: string | undefined; + record: PluginInstallRecord; +}): boolean { + const officialSpec = resolveTrustedSourceLinkedOfficialNpmSpec(params); + const officialPackageName = resolveNpmSpecPackageName(officialSpec); + const requestedPackageName = resolveNpmSpecPackageName(params.spec); + return Boolean(officialPackageName && requestedPackageName === officialPackageName); } function isTrustedSourceLinkedOfficialBridgeNpmInstall(params: { @@ -542,6 +577,7 @@ function isBridgeClawHubInstall(params: { function resolveNpmUpdateSpecs(params: { record: PluginInstallRecord; specOverride?: string; + officialSpecOverride?: string; updateChannel?: UpdateChannel; }): { installSpec?: string; @@ -549,7 +585,7 @@ function resolveNpmUpdateSpecs(params: { fallbackSpec?: string; fallbackLabel?: string; } { - const recordSpec = params.specOverride ?? params.record.spec; + const recordSpec = params.specOverride ?? params.officialSpecOverride ?? params.record.spec; if (!recordSpec) { return {}; } @@ -567,6 +603,7 @@ function resolveNpmUpdateSpecs(params: { function resolveClawHubUpdateSpecs(params: { record: PluginInstallRecord; + officialSpecOverride?: string; updateChannel?: UpdateChannel; }): { installSpec?: string; @@ -574,10 +611,11 @@ function resolveClawHubUpdateSpecs(params: { fallbackSpec?: string; fallbackLabel?: string; } { - if (!params.record.clawhubPackage) { + if (!params.officialSpecOverride && !params.record.clawhubPackage) { return {}; } - const recordSpec = params.record.spec ?? `clawhub:${params.record.clawhubPackage}`; + const recordSpec = + params.officialSpecOverride ?? params.record.spec ?? `clawhub:${params.record.clawhubPackage}`; return resolveClawHubInstallSpecsForUpdateChannel({ spec: recordSpec, updateChannel: params.updateChannel, @@ -726,6 +764,7 @@ export async function updateNpmInstalledPlugins(params: { pluginIds?: string[]; skipIds?: Set; skipDisabledPlugins?: boolean; + syncOfficialPluginInstalls?: boolean; disableOnFailure?: boolean; timeoutMs?: number; dryRun?: boolean; @@ -787,6 +826,13 @@ export async function updateNpmInstalledPlugins(params: { continue; } + const officialNpmSpec = params.syncOfficialPluginInstalls + ? resolveTrustedSourceLinkedOfficialNpmSpec({ pluginId, record }) + : undefined; + const officialClawHubSpec = params.syncOfficialPluginInstalls + ? resolveTrustedSourceLinkedOfficialClawHubSpec({ pluginId, record }) + : undefined; + if (normalizedPluginConfig) { const enableState = resolveEffectiveEnableState({ id: pluginId, @@ -794,7 +840,7 @@ export async function updateNpmInstalledPlugins(params: { config: normalizedPluginConfig, rootConfig: params.config, }); - if (!enableState.enabled) { + if (!enableState.enabled && !officialNpmSpec && !officialClawHubSpec) { outcomes.push({ pluginId, status: "skipped", @@ -823,6 +869,7 @@ export async function updateNpmInstalledPlugins(params: { ? resolveNpmUpdateSpecs({ record, specOverride: params.specOverrides?.[pluginId], + officialSpecOverride: officialNpmSpec, updateChannel: params.updateChannel, }) : undefined; @@ -830,6 +877,7 @@ export async function updateNpmInstalledPlugins(params: { record.source === "clawhub" ? resolveClawHubUpdateSpecs({ record, + officialSpecOverride: officialClawHubSpec, updateChannel: params.updateChannel, }) : undefined; @@ -877,7 +925,7 @@ export async function updateNpmInstalledPlugins(params: { continue; } - if (record.source === "clawhub" && !record.clawhubPackage) { + if (record.source === "clawhub" && !record.clawhubPackage && !officialClawHubSpec) { outcomes.push({ pluginId, status: "skipped",