Skip to content

Commit 2014c23

Browse files
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 <patrick.a.erichsen@gmail.com>
1 parent 813fe0a commit 2014c23

6 files changed

Lines changed: 378 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ Docs: https://docs.openclaw.ai
100100
- 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.
101101
- Discord/streaming: show live reasoning text in progress drafts instead of a bare `Reasoning` status line.
102102
- 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.
103+
- 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.
103104
- 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.
104105
- 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.
105106
- 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.

src/cli/update-cli.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,8 @@ vi.mock("../utils.js", async (importOriginal) => {
168168
});
169169

170170
vi.mock("../plugins/update.js", () => ({
171+
resolveTrustedSourceLinkedOfficialClawHubSpec: vi.fn(() => undefined),
172+
resolveTrustedSourceLinkedOfficialNpmSpec: vi.fn(() => undefined),
171173
syncPluginsForUpdateChannel: (...args: unknown[]) => syncPluginsForUpdateChannel(...args),
172174
updateNpmInstalledPlugins: (...args: unknown[]) => updateNpmInstalledPlugins(...args),
173175
}));
@@ -2439,13 +2441,14 @@ describe("update-cli", () => {
24392441
| OpenClawConfig
24402442
| undefined;
24412443
const updateCall = vi.mocked(updateNpmInstalledPlugins).mock.calls[0]?.[0] as
2442-
| { skipDisabledPlugins?: boolean }
2444+
| { skipDisabledPlugins?: boolean; syncOfficialPluginInstalls?: boolean }
24432445
| undefined;
24442446
expect(syncConfig?.plugins?.installs).toEqual(pluginInstallRecords);
24452447
expect(syncConfig?.update?.channel).toBe("beta");
24462448
expect(syncConfig?.gateway?.auth).toBeUndefined();
24472449
expect(syncConfig?.plugins?.entries).toBeUndefined();
24482450
expect(updateCall?.skipDisabledPlugins).toBe(true);
2451+
expect(updateCall?.syncOfficialPluginInstalls).toBe(true);
24492452
});
24502453

24512454
it("persists channel and runs post-update work after switching from package to git", async () => {

src/cli/update-cli/update-command.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,84 @@ describe("collectMissingPluginInstallPayloads", () => {
253253
await fs.rm(tmpDir, { recursive: true, force: true });
254254
}
255255
});
256+
257+
it("keeps disabled trusted official npm records eligible for payload repair when requested", async () => {
258+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-plugin-payload-"));
259+
const missingDir = path.join(tmpDir, "state", "npm", "node_modules", "@openclaw", "codex");
260+
try {
261+
await expect(
262+
collectMissingPluginInstallPayloads({
263+
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
264+
skipDisabledPlugins: true,
265+
syncOfficialPluginInstalls: true,
266+
config: {
267+
plugins: {
268+
entries: {
269+
codex: {
270+
enabled: false,
271+
},
272+
},
273+
},
274+
},
275+
records: {
276+
codex: {
277+
source: "npm",
278+
spec: "@openclaw/codex@2026.5.3",
279+
resolvedName: "@openclaw/codex",
280+
resolvedSpec: "@openclaw/codex@2026.5.3",
281+
installPath: missingDir,
282+
},
283+
},
284+
}),
285+
).resolves.toEqual([
286+
{
287+
pluginId: "codex",
288+
installPath: missingDir,
289+
reason: "missing-package-dir",
290+
},
291+
]);
292+
} finally {
293+
await fs.rm(tmpDir, { recursive: true, force: true });
294+
}
295+
});
296+
297+
it("keeps disabled trusted official ClawHub records eligible for payload repair when requested", async () => {
298+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-plugin-payload-"));
299+
const missingDir = path.join(tmpDir, "state", "clawhub", "diagnostics-otel");
300+
try {
301+
await expect(
302+
collectMissingPluginInstallPayloads({
303+
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
304+
skipDisabledPlugins: true,
305+
syncOfficialPluginInstalls: true,
306+
config: {
307+
plugins: {
308+
entries: {
309+
"diagnostics-otel": {
310+
enabled: false,
311+
},
312+
},
313+
},
314+
},
315+
records: {
316+
"diagnostics-otel": {
317+
source: "clawhub",
318+
spec: "clawhub:@openclaw/diagnostics-otel@2026.5.3",
319+
installPath: missingDir,
320+
},
321+
},
322+
}),
323+
).resolves.toEqual([
324+
{
325+
pluginId: "diagnostics-otel",
326+
installPath: missingDir,
327+
reason: "missing-package-dir",
328+
},
329+
]);
330+
} finally {
331+
await fs.rm(tmpDir, { recursive: true, force: true });
332+
}
333+
});
256334
});
257335

258336
describe("shouldUseLegacyProcessRestartAfterUpdate", () => {

src/cli/update-cli/update-command.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ import {
5858
withPluginInstallRecords,
5959
} from "../../plugins/installed-plugin-index-records.js";
6060
import {
61+
resolveTrustedSourceLinkedOfficialClawHubSpec,
62+
resolveTrustedSourceLinkedOfficialNpmSpec,
6163
syncPluginsForUpdateChannel,
6264
updateNpmInstalledPlugins,
6365
type PluginUpdateIntegrityDriftParams,
@@ -190,6 +192,7 @@ export async function collectMissingPluginInstallPayloads(params: {
190192
records: Record<string, PluginInstallRecord>;
191193
config?: OpenClawConfig;
192194
skipDisabledPlugins?: boolean;
195+
syncOfficialPluginInstalls?: boolean;
193196
env?: NodeJS.ProcessEnv;
194197
}): Promise<MissingPluginInstallPayload[]> {
195198
const env = params.env ?? process.env;
@@ -204,14 +207,20 @@ export async function collectMissingPluginInstallPayloads(params: {
204207
if (!isTrackedPackageInstallRecord(record)) {
205208
continue;
206209
}
210+
const officialNpmSpec = params.syncOfficialPluginInstalls
211+
? resolveTrustedSourceLinkedOfficialNpmSpec({ pluginId, record })
212+
: undefined;
213+
const officialClawHubSpec = params.syncOfficialPluginInstalls
214+
? resolveTrustedSourceLinkedOfficialClawHubSpec({ pluginId, record })
215+
: undefined;
207216
if (normalizedPluginConfig && params.config) {
208217
const enableState = resolveEffectiveEnableState({
209218
id: pluginId,
210219
origin: "global",
211220
config: normalizedPluginConfig,
212221
rootConfig: params.config,
213222
});
214-
if (!enableState.enabled) {
223+
if (!enableState.enabled && !officialNpmSpec && !officialClawHubSpec) {
215224
continue;
216225
}
217226
}
@@ -1168,6 +1177,7 @@ async function updatePluginsAfterCoreUpdate(params: {
11681177
records,
11691178
config: pluginConfig,
11701179
skipDisabledPlugins: true,
1180+
syncOfficialPluginInstalls: true,
11711181
});
11721182
if (missing.length === 0) {
11731183
return [];
@@ -1188,6 +1198,21 @@ async function updatePluginsAfterCoreUpdate(params: {
11881198
defaultRuntime.log(theme.warn(warning.message));
11891199
}
11901200
}
1201+
const repairResult = await updateNpmInstalledPlugins({
1202+
config: pluginConfig,
1203+
pluginIds: missingIds,
1204+
timeoutMs: params.timeoutMs,
1205+
updateChannel: params.channel,
1206+
skipDisabledPlugins: true,
1207+
syncOfficialPluginInstalls: true,
1208+
disableOnFailure: true,
1209+
logger: pluginLogger,
1210+
onIntegrityDrift: onPluginIntegrityDrift,
1211+
});
1212+
pluginConfig = repairResult.config;
1213+
pluginsChanged ||= repairResult.changed;
1214+
npmPluginsChanged ||= repairResult.changed;
1215+
pluginUpdateOutcomes.push(...repairResult.outcomes);
11911216
return missingIds;
11921217
};
11931218

@@ -1199,6 +1224,8 @@ async function updatePluginsAfterCoreUpdate(params: {
11991224
updateChannel: params.channel,
12001225
skipIds: new Set([...syncResult.summary.switchedToNpm, ...missingPayloadIds]),
12011226
skipDisabledPlugins: true,
1227+
syncOfficialPluginInstalls: true,
1228+
disableOnFailure: true,
12021229
logger: pluginLogger,
12031230
onIntegrityDrift: onPluginIntegrityDrift,
12041231
});
@@ -1217,6 +1244,7 @@ async function updatePluginsAfterCoreUpdate(params: {
12171244
records: pluginConfig.plugins?.installs ?? {},
12181245
config: pluginConfig,
12191246
skipDisabledPlugins: true,
1247+
syncOfficialPluginInstalls: true,
12201248
});
12211249
pluginUpdateOutcomes.push(
12221250
...remainingMissingPayloads

0 commit comments

Comments
 (0)