fix(update): skip disabled plugins during post-update sync (#73970)

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
This commit is contained in:
Vincent Koc
2026-04-28 19:36:11 -07:00
committed by GitHub
parent c65ec4d68c
commit 43da089790
5 changed files with 172 additions and 0 deletions

View File

@@ -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.

View File

@@ -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 () => {

View File

@@ -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({

View File

@@ -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(

View File

@@ -469,6 +469,7 @@ export async function updateNpmInstalledPlugins(params: {
logger?: PluginUpdateLogger;
pluginIds?: string[];
skipIds?: Set<string>;
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,