diff --git a/CHANGELOG.md b/CHANGELOG.md index 07b3f515bb7..a3947c27233 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai - Gateway/reload: bound default restart deferral and SIGUSR1 restart drain to five minutes while preserving explicit `deferralTimeoutMs: 0` indefinite waits, so stale active work accounting cannot block config reloads forever. Thanks @vincentkoc. - Active Memory: register the prompt-build hook with the configured recall timeout plus setup grace instead of the 150s maximum budget, so default memory recall cannot delay turn startup for multiple minutes. Thanks @vincentkoc. - CLI/channels logs: reuse the rolling log-file resolver so `openclaw channels logs` falls back to the active dated log across date boundaries without reading unrelated custom log files. Fixes #42875; carries forward #42904 and #43043. Thanks @ethanclaw and @wdskuki. +- CLI/update: skip tracked plugins disabled in config during post-update plugin sync before npm, ClawHub, or marketplace update checks, preserving their install records without failing the update. Fixes #73880. Thanks @islandpreneur007. - Security/audit: recognize dangerous node command IDs as valid `gateway.nodes.denyCommands` entries, so audit only warns on real typos or unsupported patterns. (#56923) Thanks @chziyue. - Telegram/exec approvals: stop treating general Telegram chat allowlists and `defaultTo` routes as native exec approvers; Telegram now uses explicit `execApprovals.approvers` or owner identity from `commands.ownerAllowFrom`, matching the first-pairing owner bootstrap path. Thanks @pashpashpash. - Chat commands: route sensitive group `/diagnostics` and `/export-trajectory` approvals and results to a private owner route, preferring same-surface DMs before falling back to the first configured owner route, so Discord group invocations can land in Telegram when that is the primary owner interface. Thanks @pashpashpash. diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 04a8f8aecc9..acb7cbd92ba 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -1935,10 +1935,14 @@ describe("update-cli", () => { const syncConfig = vi.mocked(syncPluginsForUpdateChannel).mock.calls[0]?.[0]?.config as | OpenClawConfig | undefined; + const updateCall = vi.mocked(updateNpmInstalledPlugins).mock.calls[0]?.[0] as + | { skipDisabledPlugins?: 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); }); it("persists channel and runs post-update work after switching from package to git", async () => { diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 7ced0ad50e8..52be8334a49 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -742,6 +742,7 @@ async function updatePluginsAfterCoreUpdate(params: { config: pluginConfig, timeoutMs: params.timeoutMs, skipIds: new Set(syncResult.summary.switchedToNpm), + skipDisabledPlugins: true, logger: pluginLogger, onIntegrityDrift: async (drift) => { integrityDrifts.push({ diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 0e01ae442bc..acd134135d2 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -641,6 +641,151 @@ describe("updateNpmInstalledPlugins", () => { expect(installPluginFromNpmSpecMock).toHaveBeenCalledTimes(1); }); + it.each([ + { + source: "npm", + config: { + plugins: { + entries: { + demo: { + enabled: false, + config: { preserved: true }, + }, + }, + installs: { + demo: { + source: "npm" as const, + spec: "@acme/demo", + installPath: "/tmp/demo", + resolvedName: "@acme/demo", + }, + }, + }, + } satisfies OpenClawConfig, + }, + { + source: "ClawHub", + config: { + plugins: { + entries: { + demo: { + enabled: false, + config: { preserved: true }, + }, + }, + installs: { + demo: { + source: "clawhub" as const, + spec: "clawhub:demo", + installPath: "/tmp/demo", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + }, + }, + }, + } satisfies OpenClawConfig, + }, + { + source: "marketplace", + config: { + plugins: { + entries: { + demo: { + enabled: false, + config: { preserved: true }, + }, + }, + installs: { + demo: { + source: "marketplace" as const, + installPath: "/tmp/demo", + marketplaceSource: "acme/plugins", + marketplacePlugin: "demo", + }, + }, + }, + } satisfies OpenClawConfig, + }, + ])("skips disabled $source installs before update network calls", async ({ config }) => { + installPluginFromNpmSpecMock.mockRejectedValue(new Error("npm installer should not run")); + installPluginFromClawHubMock.mockRejectedValue(new Error("ClawHub installer should not run")); + installPluginFromMarketplaceMock.mockRejectedValue( + new Error("marketplace installer should not run"), + ); + + const result = await updateNpmInstalledPlugins({ + config, + skipDisabledPlugins: true, + }); + + expect(runCommandWithTimeoutMock).not.toHaveBeenCalled(); + expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled(); + expect(installPluginFromClawHubMock).not.toHaveBeenCalled(); + expect(installPluginFromMarketplaceMock).not.toHaveBeenCalled(); + expect(result.changed).toBe(false); + expect(result.config).toBe(config); + expect(result.config.plugins?.installs?.demo).toEqual(config.plugins.installs.demo); + expect(result.config.plugins?.entries?.demo).toEqual({ + enabled: false, + config: { preserved: true }, + }); + expect(result.outcomes).toEqual([ + { + pluginId: "demo", + status: "skipped", + message: 'Skipping "demo" (disabled in config).', + }, + ]); + }); + + it("keeps enabled tracked plugin update failures fatal when disabled skipping is enabled", async () => { + installPluginFromNpmSpecMock.mockResolvedValue({ + ok: false, + error: "registry timeout", + }); + const config = { + plugins: { + entries: { + demo: { + enabled: true, + }, + }, + installs: { + demo: { + source: "npm" as const, + spec: "@acme/demo", + installPath: "/tmp/demo", + }, + }, + }, + } satisfies OpenClawConfig; + + const result = await updateNpmInstalledPlugins({ + config, + skipDisabledPlugins: true, + dryRun: true, + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@acme/demo", + expectedPluginId: "demo", + dryRun: true, + }), + ); + expect(result.changed).toBe(false); + expect(result.config).toBe(config); + expect(result.outcomes).toEqual([ + { + pluginId: "demo", + status: "error", + message: "Failed to check demo: registry timeout", + }, + ]); + }); + it("aborts exact pinned npm plugin updates on integrity drift by default", async () => { const warn = vi.fn(); installPluginFromNpmSpecMock.mockImplementation( diff --git a/src/plugins/update.ts b/src/plugins/update.ts index c6fcb78ba3e..f54724ae936 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -469,6 +469,7 @@ export async function updateNpmInstalledPlugins(params: { logger?: PluginUpdateLogger; pluginIds?: string[]; skipIds?: Set; + skipDisabledPlugins?: boolean; timeoutMs?: number; dryRun?: boolean; dangerouslyForceUnsafeInstall?: boolean; @@ -478,6 +479,9 @@ export async function updateNpmInstalledPlugins(params: { const logger = params.logger ?? {}; const installs = params.config.plugins?.installs ?? {}; const targets = params.pluginIds?.length ? params.pluginIds : Object.keys(installs); + const normalizedPluginConfig = params.skipDisabledPlugins + ? normalizePluginsConfig(params.config.plugins) + : undefined; const outcomes: PluginUpdateOutcome[] = []; let next = params.config; let changed = false; @@ -502,6 +506,23 @@ export async function updateNpmInstalledPlugins(params: { continue; } + if (normalizedPluginConfig) { + const enableState = resolveEffectiveEnableState({ + id: pluginId, + origin: "global", + config: normalizedPluginConfig, + rootConfig: params.config, + }); + if (!enableState.enabled) { + outcomes.push({ + pluginId, + status: "skipped", + message: `Skipping "${pluginId}" (${enableState.reason ?? "disabled by plugin config"}).`, + }); + continue; + } + } + if (record.source !== "npm" && record.source !== "marketplace" && record.source !== "clawhub") { outcomes.push({ pluginId,