fix(plugin-sdk): keep published pre-split plugin imports loading after upgrade (#124086)

* fix(plugin-sdk): keep published pre-split plugin imports loading after upgrade

Same bug class as #124041: published plugin artifacts import SDK names at
module top level, so removing them from the barrel makes the installed
plugin fail to load (voice-call/matrix doctor contracts silently never run
their migrations; whatsapp and slack channels fail outright) after a core
upgrade. Verified against the actual npm tarballs (2026.7.2-beta.7):

- openclaw/plugin-sdk/runtime-doctor: voice-call + matrix doctor contracts
  import repair names (archiveLegacyStateSource, detect/repair state DB
  schema, plugin install-path repair, removePluginFromConfig,
  createPluginStateSyncKeyedStore) that moved to doctor-repair-runtime.
- openclaw/plugin-sdk/channel-feedback: whatsapp imports
  shouldAckReactionForWhatsApp (owner policy moved in-plugin by #121257).
- openclaw/plugin-sdk/channel-outbound: slack imports
  resolveChannelProgressDraftRender (render key retired by #122927).

Adds deprecated load-only bridges with named removal windows, bumps the
SDK surface budgets with comments, and locks behavior with unit tests
plus a loader fixture that fails without the bridges.

* test(plugin-sdk): cover the repair bridge in the runtime-doctor facade surface lock
This commit is contained in:
Peter Steinberger
2026-08-15 01:05:08 -07:00
committed by GitHub
parent f4871eb86b
commit c83c3bc3a9
8 changed files with 273 additions and 9 deletions
+18 -4
View File
@@ -157,7 +157,12 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
"channel-lifecycle": 23,
// +1: shared ingress error factory projected through the deprecated message barrel.
// +1: shared ingress retention defaults projected through the deprecated message barrel.
"channel-message": 131,
// +1: WhatsApp ack-policy bridge counted via channel-message's wildcard re-export.
"channel-message": 132,
// +2: Slack progress-draft render bridge (function + mode type).
"channel-outbound": 2,
// +2: WhatsApp ack-policy bridge (function + mode type).
"channel-feedback": 2,
"channel-pairing": 0,
"channel-policy": 7,
"channel-send-result": 1,
@@ -276,7 +281,10 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
// +11: Computer Use schemas/types plus parsers, compiler, and provider registration.
// +6: Computer Use v2 action, result, and capability contracts.
// +1: opaque channel participant evidence preservation without mint authority.
4324,
// +6: load-only bridges for published pre-split plugin artifacts
// (voice-call/matrix runtime-doctor repair names, WhatsApp ack policy,
// Slack progress-draft render) so installed plugins survive upgrade (#124041 class).
4330,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
@@ -346,7 +354,10 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
// -2: retire the dead progress-draft render reader; it counted twice via
// channel-outbound and channel-message's wildcard re-export of it.
// +4: Computer Use wire parsers, validator compiler, and provider registration.
2574,
// +3: load-only bridges for published pre-split plugin artifacts
// (voice-call/matrix runtime-doctor repair names, WhatsApp ack policy,
// Slack progress-draft render) so installed plugins survive upgrade (#124041 class).
2577,
env,
),
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
@@ -362,7 +373,10 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
// +7: restore still-existing deprecated inbound-dispatch compatibility re-exports.
// +6: source-compatible harness contracts retained during the V2 migration window.
// +4: shipped default-agent resolver projections retained during explicit-owner migration.
1146,
// +5: load-only bridges for published pre-split plugin artifacts
// (voice-call/matrix runtime-doctor repair names, WhatsApp ack policy,
// Slack progress-draft render) so installed plugins survive upgrade (#124041 class).
1151,
env,
),
publicWildcardReexports: readPluginSdkSurfaceBudgetEnv(
+44
View File
@@ -12,6 +12,50 @@ export {
type AckReactionScope,
} from "../channels/ack-reactions.js";
export { logAckFailure, logTypingFailure, type LogFn } from "../channels/logging.js";
import { shouldAckReaction as sharedAckReactionGate } from "../channels/ack-reactions.js";
/** @deprecated Owner policy moved into the WhatsApp plugin (#121257). */
export type WhatsAppAckReactionMode = "always" | "mentions" | "never";
/**
* @deprecated Load-only bridge: the published WhatsApp channel package
* (2026.7.2-beta.7 and earlier) imports this at module top level, so removing
* it makes the installed plugin fail to load after a core upgrade. Behavior
* preserved verbatim from the pre-#121257 owner. Remove once managed releases
* have replaced the old npm latest/extended-stable packages and their upgrade
* window has closed.
*/
export function shouldAckReactionForWhatsApp(params: {
emoji: string;
isDirect: boolean;
isGroup: boolean;
directEnabled: boolean;
groupMode: WhatsAppAckReactionMode;
wasMentioned: boolean;
groupActivated: boolean;
}): boolean {
if (!params.emoji) {
return false;
}
if (params.isDirect) {
return params.directEnabled;
}
if (!params.isGroup || params.groupMode === "never") {
return false;
}
if (params.groupMode === "always") {
return true;
}
return sharedAckReactionGate({
scope: "group-mentions",
isDirect: false,
isGroup: true,
isMentionableGroup: true,
canDetectMention: true,
effectiveWasMentioned: params.wasMentioned,
shouldBypassMention: params.groupActivated,
});
}
export { missingTargetError } from "../infra/outbound/target-errors.js";
export {
BUILD_TOOL_TOKENS,
+24
View File
@@ -123,6 +123,30 @@ export {
createChannelProgressDraftCompositor,
createChannelProgressReceiptTracker,
} from "../channels/progress-draft-compositor.js";
import {
resolveChannelProgressDraftConfig as readProgressDraftConfig,
type StreamingCompatEntry as ProgressDraftCompatEntry,
} from "../channels/streaming.js";
/** @deprecated The streaming.progress.render key was retired (#122927). */
export type ChannelProgressDraftRenderMode = "rich" | "text";
/**
* @deprecated Load-only bridge: the published Slack channel package
* (2026.7.2-beta.7 and earlier) imports this at module top level, so removing
* it makes the installed plugin fail to load after a core upgrade. The config
* key it read is retired and doctor strips it, so this resolves the same
* "text"/"rich" answer pre-doctor configs produced and the default otherwise.
* Remove once managed releases have replaced the old npm latest/extended-stable
* packages and their upgrade window has closed.
*/
export function resolveChannelProgressDraftRender(
entry: ProgressDraftCompatEntry | null | undefined,
defaultValue: ChannelProgressDraftRenderMode = "text",
): ChannelProgressDraftRenderMode {
const configured = (readProgressDraftConfig(entry) as { render?: unknown }).render;
return configured === "rich" || configured === "text" ? configured : defaultValue;
}
export type {
ChannelProgressDraftCompositorLine,
ChannelProgressDraftCompositorSnapshot,
+21 -4
View File
@@ -1,16 +1,33 @@
import { describe, expect, it } from "vitest";
import { createPluginStateSyncKeyedStore } from "../plugin-state/plugin-state-store.js";
import * as doctorRepairRuntime from "./doctor-repair-runtime.js";
import * as runtimeDoctorMigrations from "./runtime-doctor-migrations.js";
import * as legacyRuntimeDoctor from "./runtime-doctor.js";
describe("legacy runtime-doctor package facade", () => {
it("is exactly the dependency-light migration surface", () => {
expect(Object.keys(legacyRuntimeDoctor).toSorted()).toEqual(
Object.keys(runtimeDoctorMigrations).toSorted(),
);
it("is exactly the migration surface plus the published-artifact repair bridge", () => {
// Bridge names ship for published pre-split doctor artifacts (#124041
// class); delete them here alongside the runtime-doctor.ts bridge.
const expected = [
...new Set([
...Object.keys(runtimeDoctorMigrations),
...Object.keys(doctorRepairRuntime),
"createPluginStateSyncKeyedStore",
]),
].toSorted();
expect(Object.keys(legacyRuntimeDoctor).toSorted()).toEqual(expected);
for (const key of Object.keys(runtimeDoctorMigrations)) {
expect(legacyRuntimeDoctor[key as keyof typeof legacyRuntimeDoctor]).toBe(
runtimeDoctorMigrations[key as keyof typeof runtimeDoctorMigrations],
);
}
for (const key of Object.keys(doctorRepairRuntime)) {
expect(legacyRuntimeDoctor[key as keyof typeof legacyRuntimeDoctor]).toBe(
doctorRepairRuntime[key as keyof typeof doctorRepairRuntime],
);
}
expect(legacyRuntimeDoctor.createPluginStateSyncKeyedStore).toBe(
createPluginStateSyncKeyedStore,
);
});
});
+11
View File
@@ -3,3 +3,14 @@
* Current source must import `runtime-doctor-migrations` directly.
*/
export * from "./runtime-doctor-migrations.js";
/**
* @deprecated Load-only bridge: published pre-split doctor artifacts
* (voice-call/matrix 2026.7.2-beta.7 and earlier) import these repair names
* from this subpath; without them the contract module fails to load and the
* plugin's doctor migrations silently never run. Remove once managed releases
* have replaced the old npm latest/extended-stable packages and their upgrade
* window has closed. Current source imports `doctor-repair-runtime` (heavy)
* and `plugin-state-store-runtime` directly.
*/
export * from "./doctor-repair-runtime.js";
export { createPluginStateSyncKeyedStore } from "../plugin-state/plugin-state-store.js";
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { shouldAckReactionForWhatsApp, type WhatsAppAckReactionMode } from "./channel-feedback.js";
import { resolveChannelProgressDraftRender } from "./channel-outbound.js";
// Behavior locks for the deprecated load-only bridges consumed by published
// pre-split plugin artifacts (2026.7.2-beta.7 and earlier). Delete alongside
// the bridges when their upgrade window closes.
describe("shouldAckReactionForWhatsApp bridge", () => {
const base = {
emoji: "👍",
isDirect: false,
isGroup: true,
directEnabled: true,
groupMode: "mentions" as WhatsAppAckReactionMode,
wasMentioned: false,
groupActivated: false,
};
const cases: Array<{ name: string; params: Partial<typeof base>; expected: boolean }> = [
{ name: "empty emoji never acks", params: { emoji: "" }, expected: false },
{ name: "direct follows directEnabled true", params: { isDirect: true }, expected: true },
{
name: "direct follows directEnabled false",
params: { isDirect: true, directEnabled: false },
expected: false,
},
{ name: "non-group non-direct never acks", params: { isGroup: false }, expected: false },
{ name: "group mode never", params: { groupMode: "never" }, expected: false },
{ name: "group mode always", params: { groupMode: "always" }, expected: true },
{ name: "mentions mode without mention", params: {}, expected: false },
{ name: "mentions mode with mention", params: { wasMentioned: true }, expected: true },
{
name: "mentions mode activated group bypasses mention",
params: { groupActivated: true },
expected: true,
},
];
it.each(cases)("$name", ({ params, expected }) => {
expect(shouldAckReactionForWhatsApp({ ...base, ...params })).toBe(expected);
});
});
describe("resolveChannelProgressDraftRender bridge", () => {
it("returns the default when the retired render key is absent", () => {
expect(resolveChannelProgressDraftRender({})).toBe("text");
expect(resolveChannelProgressDraftRender(undefined, "rich")).toBe("rich");
});
it("passes through configured rich/text and ignores junk", () => {
expect(resolveChannelProgressDraftRender({ streaming: { progress: { render: "rich" } } })).toBe(
"rich",
);
expect(resolveChannelProgressDraftRender({ streaming: { progress: { render: "text" } } })).toBe(
"text",
);
expect(
resolveChannelProgressDraftRender({ streaming: { progress: { render: "bogus" } } }, "rich"),
).toBe("rich");
});
});
@@ -1113,7 +1113,9 @@ describe("plugin-sdk subpath exports", () => {
"shouldAckReaction",
"DEFAULT_EMOJIS",
]);
expectSourceOmits("channel-feedback", [
// The load-only WhatsApp bridge lives in the barrel for published
// pre-#121257 artifacts; the owner file stays free of channel policy.
expectSourceMentions("channel-feedback", [
"shouldAckReactionForWhatsApp",
"WhatsAppAckReactionMode",
]);
@@ -123,6 +123,71 @@ function writePreManagedLlamaCppProviderFixture() {
return pluginRoot;
}
function writePreSplitSdkBridgeConsumerFixture() {
const pluginRoot = tempDirs.make("openclaw-plugin-loader-");
fs.mkdirSync(path.join(pluginRoot, "dist"));
fs.writeFileSync(
path.join(pluginRoot, "package.json"),
JSON.stringify(
{
name: "@openclaw/sdk-bridge-consumer",
version: "2026.7.2-beta.7",
type: "module",
openclaw: {
extensions: ["./dist/index.js"],
runtimeExtensions: ["./dist/index.js"],
},
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginRoot, "openclaw.plugin.json"),
JSON.stringify(
{
id: "sdk-bridge-consumer",
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
},
null,
2,
),
"utf-8",
);
// Import shapes copied from published 2026.7.2-beta.7 artifacts:
// voice-call/matrix doctor contracts (runtime-doctor), whatsapp ack policy
// (channel-feedback), slack progress-draft render (channel-outbound).
fs.writeFileSync(
path.join(pluginRoot, "dist", "index.js"),
[
'import { archiveLegacyStateSource, detectOpenClawStateDatabaseSchemaMigrations, repairOpenClawStateDatabaseSchema, detectPluginInstallPathIssue, formatPluginInstallPathIssue, removePluginFromConfig, createPluginStateSyncKeyedStore } from "openclaw/plugin-sdk/runtime-doctor";',
'import { shouldAckReactionForWhatsApp } from "openclaw/plugin-sdk/channel-feedback";',
'import { resolveChannelProgressDraftRender } from "openclaw/plugin-sdk/channel-outbound";',
'export default { id: "sdk-bridge-consumer", register() {',
" const bridged = [",
" archiveLegacyStateSource,",
" detectOpenClawStateDatabaseSchemaMigrations,",
" repairOpenClawStateDatabaseSchema,",
" detectPluginInstallPathIssue,",
" formatPluginInstallPathIssue,",
" removePluginFromConfig,",
" createPluginStateSyncKeyedStore,",
" shouldAckReactionForWhatsApp,",
" resolveChannelProgressDraftRender,",
" ];",
' if (bridged.some((entry) => typeof entry !== "function")) throw new Error("missing bridge");',
"} };",
].join("\n"),
"utf-8",
);
return pluginRoot;
}
afterEach(() => {
vi.resetModules();
vi.doUnmock("./plugin-module-loader-cache.js");
@@ -242,4 +307,30 @@ describe("createPluginModuleLoader", () => {
expect(registry.plugins.find((plugin) => plugin.id === "llama-cpp")?.status).toBe("loaded");
});
it("loads published pre-split SDK bridge imports (doctor repair, WhatsApp ack, Slack render)", async () => {
const { loadOpenClawPlugins } = await importFreshModule<typeof import("./loader.js")>(
import.meta.url,
"./loader.js?scope=sdk-bridge-upgrade-compat",
);
const pluginRoot = writePreSplitSdkBridgeConsumerFixture();
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tempDirs.make("openclaw-plugin-loader-");
const registry = loadOpenClawPlugins({
cache: false,
onlyPluginIds: ["sdk-bridge-consumer"],
config: {
plugins: {
enabled: true,
load: { paths: [pluginRoot] },
allow: ["sdk-bridge-consumer"],
entries: { "sdk-bridge-consumer": { enabled: true } },
},
},
});
const entry = registry.plugins.find((plugin) => plugin.id === "sdk-bridge-consumer");
expect(entry?.error ?? null).toBeNull();
expect(entry?.status).toBe("loaded");
});
});