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>
This commit is contained in:
Vincent Koc
2026-05-05 17:27:32 -07:00
committed by GitHub
parent 813fe0a3be
commit 2014c2327b
6 changed files with 378 additions and 19 deletions

View File

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

View File

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

View File

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

View File

@@ -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<string, PluginInstallRecord>;
config?: OpenClawConfig;
skipDisabledPlugins?: boolean;
syncOfficialPluginInstalls?: boolean;
env?: NodeJS.ProcessEnv;
}): Promise<MissingPluginInstallPayload[]> {
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

View File

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

View File

@@ -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<string>;
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",