Skip to content

Commit a108222

Browse files
committed
fix(plugins): sync official clawhub installs during update
1 parent cee895d commit a108222

6 files changed

Lines changed: 189 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ Docs: https://docs.openclaw.ai
8686

8787
### Fixes
8888

89-
- Plugins/update: keep installed official npm plugins such as Codex, Discord, and WhatsApp synced during host updates even when disabled or previously exact-pinned, while preserving third-party plugin pins. Thanks @vincentkoc.
89+
- 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.
9090
- 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.
9191
- 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.
9292
- Agents/media: avoid direct generated-media completion fallback while the announce-agent run is still pending, so async video and music completions do not duplicate raw media messages. (#77754)

src/cli/update-cli.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2325,14 +2325,14 @@ describe("update-cli", () => {
23252325
| OpenClawConfig
23262326
| undefined;
23272327
const updateCall = vi.mocked(updateNpmInstalledPlugins).mock.calls[0]?.[0] as
2328-
| { skipDisabledPlugins?: boolean; syncOfficialNpmPluginInstalls?: boolean }
2328+
| { skipDisabledPlugins?: boolean; syncOfficialPluginInstalls?: boolean }
23292329
| undefined;
23302330
expect(syncConfig?.plugins?.installs).toEqual(pluginInstallRecords);
23312331
expect(syncConfig?.update?.channel).toBe("beta");
23322332
expect(syncConfig?.gateway?.auth).toBeUndefined();
23332333
expect(syncConfig?.plugins?.entries).toBeUndefined();
23342334
expect(updateCall?.skipDisabledPlugins).toBe(true);
2335-
expect(updateCall?.syncOfficialNpmPluginInstalls).toBe(true);
2335+
expect(updateCall?.syncOfficialPluginInstalls).toBe(true);
23362336
});
23372337

23382338
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: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ describe("collectMissingPluginInstallPayloads", () => {
262262
collectMissingPluginInstallPayloads({
263263
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
264264
skipDisabledPlugins: true,
265-
syncOfficialNpmPluginInstalls: true,
265+
syncOfficialPluginInstalls: true,
266266
config: {
267267
plugins: {
268268
entries: {
@@ -293,6 +293,44 @@ describe("collectMissingPluginInstallPayloads", () => {
293293
await fs.rm(tmpDir, { recursive: true, force: true });
294294
}
295295
});
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+
});
296334
});
297335

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

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

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import {
5858
withPluginInstallRecords,
5959
} from "../../plugins/installed-plugin-index-records.js";
6060
import {
61+
resolveTrustedSourceLinkedOfficialClawHubSpec,
6162
resolveTrustedSourceLinkedOfficialNpmSpec,
6263
syncPluginsForUpdateChannel,
6364
updateNpmInstalledPlugins,
@@ -188,7 +189,7 @@ export async function collectMissingPluginInstallPayloads(params: {
188189
records: Record<string, PluginInstallRecord>;
189190
config?: OpenClawConfig;
190191
skipDisabledPlugins?: boolean;
191-
syncOfficialNpmPluginInstalls?: boolean;
192+
syncOfficialPluginInstalls?: boolean;
192193
env?: NodeJS.ProcessEnv;
193194
}): Promise<MissingPluginInstallPayload[]> {
194195
const env = params.env ?? process.env;
@@ -203,17 +204,20 @@ export async function collectMissingPluginInstallPayloads(params: {
203204
if (!isTrackedPackageInstallRecord(record)) {
204205
continue;
205206
}
206-
const officialNpmSpec = params.syncOfficialNpmPluginInstalls
207+
const officialNpmSpec = params.syncOfficialPluginInstalls
207208
? resolveTrustedSourceLinkedOfficialNpmSpec({ pluginId, record })
208209
: undefined;
210+
const officialClawHubSpec = params.syncOfficialPluginInstalls
211+
? resolveTrustedSourceLinkedOfficialClawHubSpec({ pluginId, record })
212+
: undefined;
209213
if (normalizedPluginConfig && params.config) {
210214
const enableState = resolveEffectiveEnableState({
211215
id: pluginId,
212216
origin: "global",
213217
config: normalizedPluginConfig,
214218
rootConfig: params.config,
215219
});
216-
if (!enableState.enabled && !officialNpmSpec) {
220+
if (!enableState.enabled && !officialNpmSpec && !officialClawHubSpec) {
217221
continue;
218222
}
219223
}
@@ -1118,7 +1122,7 @@ async function updatePluginsAfterCoreUpdate(params: {
11181122
records,
11191123
config: pluginConfig,
11201124
skipDisabledPlugins: true,
1121-
syncOfficialNpmPluginInstalls: true,
1125+
syncOfficialPluginInstalls: true,
11221126
});
11231127
if (missing.length === 0) {
11241128
return [];
@@ -1139,7 +1143,7 @@ async function updatePluginsAfterCoreUpdate(params: {
11391143
timeoutMs: params.timeoutMs,
11401144
updateChannel: params.channel,
11411145
skipDisabledPlugins: true,
1142-
syncOfficialNpmPluginInstalls: true,
1146+
syncOfficialPluginInstalls: true,
11431147
disableOnFailure: true,
11441148
logger: pluginLogger,
11451149
onIntegrityDrift: onPluginIntegrityDrift,
@@ -1161,7 +1165,7 @@ async function updatePluginsAfterCoreUpdate(params: {
11611165
updateChannel: params.channel,
11621166
skipIds: new Set([...syncResult.summary.switchedToNpm, ...repairedMissingPayloadIds]),
11631167
skipDisabledPlugins: true,
1164-
syncOfficialNpmPluginInstalls: true,
1168+
syncOfficialPluginInstalls: true,
11651169
disableOnFailure: true,
11661170
logger: pluginLogger,
11671171
onIntegrityDrift: onPluginIntegrityDrift,
@@ -1175,7 +1179,7 @@ async function updatePluginsAfterCoreUpdate(params: {
11751179
records: pluginConfig.plugins?.installs ?? {},
11761180
config: pluginConfig,
11771181
skipDisabledPlugins: true,
1178-
syncOfficialNpmPluginInstalls: true,
1182+
syncOfficialPluginInstalls: true,
11791183
});
11801184
pluginUpdateOutcomes.push(
11811185
...remainingMissingPayloads.map(

src/plugins/update.test.ts

Lines changed: 97 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1149,15 +1149,12 @@ describe("updateNpmInstalledPlugins", () => {
11491149
source: "npm",
11501150
spec: "@openclaw/codex@2026.5.3",
11511151
installPath,
1152-
resolvedName: "@openclaw/codex",
1153-
resolvedVersion: "2026.5.3",
1154-
resolvedSpec: "@openclaw/codex@2026.5.3",
11551152
},
11561153
},
11571154
},
11581155
},
11591156
skipDisabledPlugins: true,
1160-
syncOfficialNpmPluginInstalls: true,
1157+
syncOfficialPluginInstalls: true,
11611158
});
11621159

11631160
expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith(
@@ -1209,7 +1206,7 @@ describe("updateNpmInstalledPlugins", () => {
12091206
}),
12101207
pluginIds: ["demo"],
12111208
dryRun: true,
1212-
syncOfficialNpmPluginInstalls: true,
1209+
syncOfficialPluginInstalls: true,
12131210
});
12141211

12151212
expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith(
@@ -1220,6 +1217,101 @@ describe("updateNpmInstalledPlugins", () => {
12201217
);
12211218
});
12221219

1220+
it("updates disabled trusted official ClawHub installs through the catalog spec", async () => {
1221+
installPluginFromClawHubMock.mockResolvedValue(
1222+
createSuccessfulClawHubUpdateResult({
1223+
pluginId: "diagnostics-otel",
1224+
targetDir: "/tmp/diagnostics-otel",
1225+
version: "2026.5.4",
1226+
clawhubPackage: "@openclaw/diagnostics-otel",
1227+
}),
1228+
);
1229+
1230+
const config = createClawHubInstallConfig({
1231+
pluginId: "diagnostics-otel",
1232+
installPath: "/tmp/diagnostics-otel",
1233+
clawhubUrl: "https://clawhub.ai",
1234+
clawhubPackage: "@openclaw/diagnostics-otel",
1235+
clawhubFamily: "code-plugin",
1236+
clawhubChannel: "official",
1237+
spec: "clawhub:@openclaw/diagnostics-otel@2026.5.3",
1238+
});
1239+
const result = await updateNpmInstalledPlugins({
1240+
config: {
1241+
...config,
1242+
plugins: {
1243+
...config.plugins,
1244+
entries: {
1245+
"diagnostics-otel": {
1246+
enabled: false,
1247+
config: { preserved: true },
1248+
},
1249+
},
1250+
},
1251+
},
1252+
skipDisabledPlugins: true,
1253+
syncOfficialPluginInstalls: true,
1254+
});
1255+
1256+
expect(installPluginFromClawHubMock).toHaveBeenCalledWith(
1257+
expect.objectContaining({
1258+
spec: "clawhub:@openclaw/diagnostics-otel",
1259+
expectedPluginId: "diagnostics-otel",
1260+
}),
1261+
);
1262+
expect(result.config.plugins?.installs?.["diagnostics-otel"]).toMatchObject({
1263+
source: "clawhub",
1264+
spec: "clawhub:@openclaw/diagnostics-otel",
1265+
version: "2026.5.4",
1266+
clawhubPackage: "@openclaw/diagnostics-otel",
1267+
clawhubChannel: "official",
1268+
});
1269+
expect(result.config.plugins?.entries?.["diagnostics-otel"]).toEqual({
1270+
enabled: false,
1271+
config: { preserved: true },
1272+
});
1273+
});
1274+
1275+
it("updates bare trusted official ClawHub installs through the catalog spec", async () => {
1276+
installPluginFromClawHubMock.mockResolvedValue(
1277+
createSuccessfulClawHubUpdateResult({
1278+
pluginId: "diagnostics-prometheus",
1279+
targetDir: "/tmp/diagnostics-prometheus",
1280+
version: "2026.5.4",
1281+
clawhubPackage: "@openclaw/diagnostics-prometheus",
1282+
}),
1283+
);
1284+
1285+
const result = await updateNpmInstalledPlugins({
1286+
config: {
1287+
plugins: {
1288+
installs: {
1289+
"diagnostics-prometheus": {
1290+
source: "clawhub",
1291+
spec: "clawhub:@openclaw/diagnostics-prometheus@2026.5.3",
1292+
installPath: "/tmp/diagnostics-prometheus",
1293+
},
1294+
},
1295+
},
1296+
},
1297+
syncOfficialPluginInstalls: true,
1298+
});
1299+
1300+
expect(installPluginFromClawHubMock).toHaveBeenCalledWith(
1301+
expect.objectContaining({
1302+
spec: "clawhub:@openclaw/diagnostics-prometheus",
1303+
expectedPluginId: "diagnostics-prometheus",
1304+
}),
1305+
);
1306+
expect(result.config.plugins?.installs?.["diagnostics-prometheus"]).toMatchObject({
1307+
source: "clawhub",
1308+
spec: "clawhub:@openclaw/diagnostics-prometheus",
1309+
version: "2026.5.4",
1310+
clawhubPackage: "@openclaw/diagnostics-prometheus",
1311+
clawhubChannel: "official",
1312+
});
1313+
});
1314+
12231315
it("keeps enabled tracked plugin update failures fatal when disabled skipping is enabled", async () => {
12241316
installPluginFromNpmSpecMock.mockResolvedValue({
12251317
ok: false,

src/plugins/update.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,10 @@ function resolveNpmSpecPackageName(spec: string | undefined): string | undefined
467467
return spec ? parseRegistryNpmSpec(spec)?.name : undefined;
468468
}
469469

470+
function resolveClawHubSpecPackageName(spec: string | undefined): string | undefined {
471+
return spec ? parseClawHubPluginSpec(spec)?.name : undefined;
472+
}
473+
470474
export function resolveTrustedSourceLinkedOfficialNpmSpec(params: {
471475
pluginId: string;
472476
record: PluginInstallRecord;
@@ -491,6 +495,29 @@ export function resolveTrustedSourceLinkedOfficialNpmSpec(params: {
491495
return recordedPackageNames.includes(officialPackageName) ? officialSpec : undefined;
492496
}
493497

498+
export function resolveTrustedSourceLinkedOfficialClawHubSpec(params: {
499+
pluginId: string;
500+
record: PluginInstallRecord;
501+
}): string | undefined {
502+
if (params.record.source !== "clawhub") {
503+
return undefined;
504+
}
505+
const entry = getOfficialExternalPluginCatalogEntry(params.pluginId);
506+
if (!entry) {
507+
return undefined;
508+
}
509+
const officialSpec = resolveOfficialExternalPluginInstall(entry)?.clawhubSpec;
510+
const officialPackageName = resolveClawHubSpecPackageName(officialSpec);
511+
if (!officialSpec || !officialPackageName) {
512+
return undefined;
513+
}
514+
const recordedPackageNames = [
515+
params.record.clawhubPackage,
516+
resolveClawHubSpecPackageName(params.record.spec),
517+
].filter((value): value is string => Boolean(value));
518+
return recordedPackageNames.includes(officialPackageName) ? officialSpec : undefined;
519+
}
520+
494521
function isTrustedSourceLinkedOfficialNpmUpdate(params: {
495522
pluginId: string;
496523
spec: string | undefined;
@@ -576,17 +603,19 @@ function resolveNpmUpdateSpecs(params: {
576603

577604
function resolveClawHubUpdateSpecs(params: {
578605
record: PluginInstallRecord;
606+
officialSpecOverride?: string;
579607
updateChannel?: UpdateChannel;
580608
}): {
581609
installSpec?: string;
582610
recordSpec?: string;
583611
fallbackSpec?: string;
584612
fallbackLabel?: string;
585613
} {
586-
if (!params.record.clawhubPackage) {
614+
if (!params.officialSpecOverride && !params.record.clawhubPackage) {
587615
return {};
588616
}
589-
const recordSpec = params.record.spec ?? `clawhub:${params.record.clawhubPackage}`;
617+
const recordSpec =
618+
params.officialSpecOverride ?? params.record.spec ?? `clawhub:${params.record.clawhubPackage}`;
590619
return resolveClawHubInstallSpecsForUpdateChannel({
591620
spec: recordSpec,
592621
updateChannel: params.updateChannel,
@@ -735,7 +764,7 @@ export async function updateNpmInstalledPlugins(params: {
735764
pluginIds?: string[];
736765
skipIds?: Set<string>;
737766
skipDisabledPlugins?: boolean;
738-
syncOfficialNpmPluginInstalls?: boolean;
767+
syncOfficialPluginInstalls?: boolean;
739768
disableOnFailure?: boolean;
740769
timeoutMs?: number;
741770
dryRun?: boolean;
@@ -797,9 +826,12 @@ export async function updateNpmInstalledPlugins(params: {
797826
continue;
798827
}
799828

800-
const officialNpmSpec = params.syncOfficialNpmPluginInstalls
829+
const officialNpmSpec = params.syncOfficialPluginInstalls
801830
? resolveTrustedSourceLinkedOfficialNpmSpec({ pluginId, record })
802831
: undefined;
832+
const officialClawHubSpec = params.syncOfficialPluginInstalls
833+
? resolveTrustedSourceLinkedOfficialClawHubSpec({ pluginId, record })
834+
: undefined;
803835

804836
if (normalizedPluginConfig) {
805837
const enableState = resolveEffectiveEnableState({
@@ -808,7 +840,7 @@ export async function updateNpmInstalledPlugins(params: {
808840
config: normalizedPluginConfig,
809841
rootConfig: params.config,
810842
});
811-
if (!enableState.enabled && !officialNpmSpec) {
843+
if (!enableState.enabled && !officialNpmSpec && !officialClawHubSpec) {
812844
outcomes.push({
813845
pluginId,
814846
status: "skipped",
@@ -845,6 +877,7 @@ export async function updateNpmInstalledPlugins(params: {
845877
record.source === "clawhub"
846878
? resolveClawHubUpdateSpecs({
847879
record,
880+
officialSpecOverride: officialClawHubSpec,
848881
updateChannel: params.updateChannel,
849882
})
850883
: undefined;
@@ -892,7 +925,7 @@ export async function updateNpmInstalledPlugins(params: {
892925
continue;
893926
}
894927

895-
if (record.source === "clawhub" && !record.clawhubPackage) {
928+
if (record.source === "clawhub" && !record.clawhubPackage && !officialClawHubSpec) {
896929
outcomes.push({
897930
pluginId,
898931
status: "skipped",

0 commit comments

Comments
 (0)