feat(plugins): bridge externalized bundled updates

This commit is contained in:
Vincent Koc
2026-04-25 04:25:27 -07:00
parent ad8296e685
commit 53c3c949d0
3 changed files with 588 additions and 4 deletions

View File

@@ -0,0 +1,66 @@
export type ExternalizedBundledPluginBridge = {
/** Plugin id used while the plugin was bundled in core. */
bundledPluginId: string;
/** Plugin id declared by the external package. Defaults to bundledPluginId. */
pluginId?: string;
/** npm spec OpenClaw should install when migrating the bundled plugin out. */
npmSpec: string;
/** Bundled directory name, when it differs from bundledPluginId. */
bundledDirName?: string;
/** Legacy ids that should be treated as this plugin during enablement checks. */
legacyPluginIds?: readonly string[];
/** Channel ids that imply this plugin is enabled when configured. */
channelIds?: readonly string[];
/** Plugin ids this external package supersedes for channel selection. */
preferOver?: readonly string[];
};
const EXTERNALIZED_BUNDLED_PLUGIN_BRIDGES: readonly ExternalizedBundledPluginBridge[] = [
{
bundledPluginId: "tlon",
npmSpec: "@openclaw/tlon",
channelIds: ["tlon"],
},
{
bundledPluginId: "twitch",
npmSpec: "@openclaw/twitch",
channelIds: ["twitch", "twitch-chat"],
legacyPluginIds: ["twitch-chat"],
},
{
bundledPluginId: "synology-chat",
npmSpec: "@openclaw/synology-chat",
channelIds: ["synology-chat"],
},
];
function normalizePluginId(value: string | undefined): string {
return value?.trim() ?? "";
}
export function getExternalizedBundledPluginTargetId(
bridge: ExternalizedBundledPluginBridge,
): string {
return normalizePluginId(bridge.pluginId) || normalizePluginId(bridge.bundledPluginId);
}
export function getExternalizedBundledPluginLookupIds(
bridge: ExternalizedBundledPluginBridge,
): readonly string[] {
return Array.from(
new Set(
[
bridge.bundledPluginId,
bridge.pluginId,
...(bridge.legacyPluginIds ?? []),
...(bridge.channelIds ?? []),
]
.map(normalizePluginId)
.filter(Boolean),
),
);
}
export function listExternalizedBundledPluginBridges(): readonly ExternalizedBundledPluginBridge[] {
return EXTERNALIZED_BUNDLED_PLUGIN_BRIDGES;
}

View File

@@ -997,4 +997,272 @@ describe("syncPluginsForUpdateChannel", () => {
}
}
});
it("installs an externalized bundled plugin and rewrites its old bundled path ledger", async () => {
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
installPluginFromNpmSpecMock.mockResolvedValue(
createSuccessfulNpmUpdateResult({
pluginId: "legacy-chat",
targetDir: "/tmp/openclaw-plugins/legacy-chat",
version: "2.0.0",
npmResolution: {
name: "@openclaw/legacy-chat",
version: "2.0.0",
resolvedSpec: "@openclaw/legacy-chat@2.0.0",
},
}),
);
const result = await syncPluginsForUpdateChannel({
channel: "stable",
externalizedBundledPluginBridges: [
{
bundledPluginId: "legacy-chat",
npmSpec: "@openclaw/legacy-chat",
channelIds: ["legacy-chat"],
},
],
config: {
channels: {
"legacy-chat": {
enabled: true,
},
},
plugins: {
load: { paths: [appBundledPluginRoot("legacy-chat")] },
installs: {
"legacy-chat": {
source: "path",
sourcePath: appBundledPluginRoot("legacy-chat"),
installPath: appBundledPluginRoot("legacy-chat"),
},
},
},
},
});
expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/legacy-chat",
mode: "update",
expectedPluginId: "legacy-chat",
}),
);
expect(result.changed).toBe(true);
expect(result.summary.switchedToNpm).toEqual(["legacy-chat"]);
expect(result.summary.errors).toEqual([]);
expect(result.config.plugins?.load?.paths).toEqual([]);
expect(result.config.plugins?.installs?.["legacy-chat"]).toMatchObject({
source: "npm",
spec: "@openclaw/legacy-chat",
installPath: "/tmp/openclaw-plugins/legacy-chat",
version: "2.0.0",
resolvedName: "@openclaw/legacy-chat",
resolvedVersion: "2.0.0",
resolvedSpec: "@openclaw/legacy-chat@2.0.0",
});
});
it("does not externalize disabled bundled plugins", async () => {
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
const result = await syncPluginsForUpdateChannel({
channel: "stable",
externalizedBundledPluginBridges: [
{
bundledPluginId: "legacy-chat",
npmSpec: "@openclaw/legacy-chat",
channelIds: ["legacy-chat"],
},
],
config: {
plugins: {
entries: {
"legacy-chat": {
enabled: false,
},
},
load: { paths: [appBundledPluginRoot("legacy-chat")] },
installs: {
"legacy-chat": {
source: "path",
sourcePath: appBundledPluginRoot("legacy-chat"),
installPath: appBundledPluginRoot("legacy-chat"),
},
},
},
},
});
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
expect(result.changed).toBe(false);
expect(result.config.plugins?.installs?.["legacy-chat"]).toMatchObject({
source: "path",
});
});
it("leaves config unchanged when externalized plugin installation fails", async () => {
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
installPluginFromNpmSpecMock.mockResolvedValue({
ok: false,
error: "package unavailable",
});
const config: OpenClawConfig = {
channels: {
"legacy-chat": {
enabled: true,
},
},
plugins: {
load: { paths: [appBundledPluginRoot("legacy-chat")] },
installs: {
"legacy-chat": {
source: "path",
sourcePath: appBundledPluginRoot("legacy-chat"),
installPath: appBundledPluginRoot("legacy-chat"),
},
},
},
};
const result = await syncPluginsForUpdateChannel({
channel: "stable",
externalizedBundledPluginBridges: [
{
bundledPluginId: "legacy-chat",
npmSpec: "@openclaw/legacy-chat",
channelIds: ["legacy-chat"],
},
],
config,
});
expect(result.changed).toBe(false);
expect(result.config).toBe(config);
expect(result.summary.errors).toEqual(["Failed to update legacy-chat: package unavailable"]);
});
it("does not externalize custom local path installs that only share the old plugin id", async () => {
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
const result = await syncPluginsForUpdateChannel({
channel: "stable",
externalizedBundledPluginBridges: [
{
bundledPluginId: "legacy-chat",
npmSpec: "@openclaw/legacy-chat",
channelIds: ["legacy-chat"],
},
],
config: {
channels: {
"legacy-chat": {
enabled: true,
},
},
plugins: {
load: { paths: ["/workspace/plugins/legacy-chat"] },
installs: {
"legacy-chat": {
source: "path",
sourcePath: "/workspace/plugins/legacy-chat",
installPath: "/workspace/plugins/legacy-chat",
},
},
},
},
});
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
expect(result.changed).toBe(false);
expect(result.config.plugins?.installs?.["legacy-chat"]).toMatchObject({
source: "path",
sourcePath: "/workspace/plugins/legacy-chat",
});
});
it("does not externalize while the bundled source is still present in the current build", async () => {
mockBundledSources(
createBundledSource({
pluginId: "legacy-chat",
localPath: appBundledPluginRoot("legacy-chat"),
}),
);
const result = await syncPluginsForUpdateChannel({
channel: "stable",
externalizedBundledPluginBridges: [
{
bundledPluginId: "legacy-chat",
npmSpec: "@openclaw/legacy-chat",
channelIds: ["legacy-chat"],
},
],
config: {
channels: {
"legacy-chat": {
enabled: true,
},
},
plugins: {
load: { paths: [appBundledPluginRoot("legacy-chat")] },
installs: {
"legacy-chat": {
source: "path",
sourcePath: appBundledPluginRoot("legacy-chat"),
installPath: appBundledPluginRoot("legacy-chat"),
},
},
},
},
});
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
expect(result.changed).toBe(false);
expect(result.config.plugins?.installs?.["legacy-chat"]).toMatchObject({
source: "path",
});
});
it("removes stale bundled load paths for already-externalized npm installs", async () => {
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
const result = await syncPluginsForUpdateChannel({
channel: "stable",
externalizedBundledPluginBridges: [
{
bundledPluginId: "legacy-chat",
npmSpec: "@openclaw/legacy-chat",
channelIds: ["legacy-chat"],
},
],
config: {
channels: {
"legacy-chat": {
enabled: true,
},
},
plugins: {
load: {
paths: [appBundledPluginRoot("legacy-chat"), "/workspace/plugins/other"],
},
installs: {
"legacy-chat": {
source: "npm",
spec: "@openclaw/legacy-chat",
installPath: "/tmp/openclaw-plugins/legacy-chat",
},
},
},
},
});
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
expect(result.changed).toBe(true);
expect(result.config.plugins?.load?.paths).toEqual(["/workspace/plugins/other"]);
expect(result.config.plugins?.installs?.["legacy-chat"]).toMatchObject({
source: "npm",
spec: "@openclaw/legacy-chat",
});
});
});

View File

@@ -1,4 +1,5 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import type { NpmSpecResolution } from "../infra/install-source-utils.js";
import { resolveNpmSpecMetadata } from "../infra/install-source-utils.js";
import {
@@ -9,6 +10,13 @@ import type { UpdateChannel } from "../infra/update-channels.js";
import { resolveUserPath } from "../utils.js";
import { resolveBundledPluginSources } from "./bundled-sources.js";
import { installPluginFromClawHub } from "./clawhub.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js";
import {
getExternalizedBundledPluginLookupIds,
getExternalizedBundledPluginTargetId,
listExternalizedBundledPluginBridges,
type ExternalizedBundledPluginBridge,
} from "./externalized-bundled-plugins.js";
import {
installPluginFromNpmSpec,
PLUGIN_INSTALL_ERROR_CODE,
@@ -173,9 +181,20 @@ function buildLoadPathHelpers(existing: string[], env: NodeJS.ProcessEnv = proce
changed = true;
};
const removeMatching = (predicate: (value: string) => boolean) => {
const next = paths.filter((entry) => !predicate(entry));
if (next.length === paths.length) {
return;
}
paths = next;
resolved = resolveSet();
changed = true;
};
return {
addPath,
removePath,
removeMatching,
get changed() {
return changed;
},
@@ -185,6 +204,139 @@ function buildLoadPathHelpers(existing: string[], env: NodeJS.ProcessEnv = proce
};
}
function normalizePathSegment(value: string | undefined): string {
return (
value
?.trim()
.replaceAll("\\", "/")
.replace(/^\/+|\/+$/g, "") ?? ""
);
}
function pathEndsWithSegment(params: {
value: string | undefined;
segment: string | undefined;
env: NodeJS.ProcessEnv;
}): boolean {
const value = normalizePathSegment(params.value ? resolveUserPath(params.value, params.env) : "");
const segment = normalizePathSegment(params.segment);
return Boolean(value && segment && (value === segment || value.endsWith(`/${segment}`)));
}
function isBridgeBundledPathRecord(params: {
bridge: ExternalizedBundledPluginBridge;
bundledLocalPath?: string;
record: PluginInstallRecord;
env: NodeJS.ProcessEnv;
}): boolean {
if (params.record.source !== "path") {
return false;
}
if (
params.bundledLocalPath &&
(pathsEqual(params.record.sourcePath, params.bundledLocalPath, params.env) ||
pathsEqual(params.record.installPath, params.bundledLocalPath, params.env))
) {
return true;
}
const bundledDirName = params.bridge.bundledDirName ?? params.bridge.bundledPluginId;
return (
pathEndsWithSegment({
value: params.record.sourcePath,
segment: `extensions/${bundledDirName}`,
env: params.env,
}) ||
pathEndsWithSegment({
value: params.record.installPath,
segment: `extensions/${bundledDirName}`,
env: params.env,
})
);
}
function removeBridgeBundledLoadPaths(params: {
bridge: ExternalizedBundledPluginBridge;
loadPaths: ReturnType<typeof buildLoadPathHelpers>;
env: NodeJS.ProcessEnv;
}) {
const bundledDirName = params.bridge.bundledDirName ?? params.bridge.bundledPluginId;
params.loadPaths.removeMatching((entry) =>
pathEndsWithSegment({
value: entry,
segment: `extensions/${bundledDirName}`,
env: params.env,
}),
);
}
function resolveBridgeInstallRecord(params: {
installs: Record<string, PluginInstallRecord>;
bridge: ExternalizedBundledPluginBridge;
}): { pluginId: string; record: PluginInstallRecord } | undefined {
for (const pluginId of getExternalizedBundledPluginLookupIds(params.bridge)) {
const record = params.installs[pluginId];
if (record) {
return { pluginId, record };
}
}
return undefined;
}
function isBridgeChannelEnabledByConfig(params: {
config: OpenClawConfig;
bridge: ExternalizedBundledPluginBridge;
}): boolean {
const channels = params.config.channels;
if (!channels || typeof channels !== "object" || Array.isArray(channels)) {
return false;
}
for (const channelId of params.bridge.channelIds ?? []) {
const entry = (channels as Record<string, unknown>)[channelId];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
continue;
}
if ((entry as Record<string, unknown>).enabled === true) {
return true;
}
}
return false;
}
function isExternalizedBundledPluginEnabled(params: {
config: OpenClawConfig;
bridge: ExternalizedBundledPluginBridge;
}): boolean {
const normalized = normalizePluginsConfig(params.config.plugins);
if (!normalized.enabled) {
return false;
}
const pluginIds = getExternalizedBundledPluginLookupIds(params.bridge);
if (
pluginIds.some(
(pluginId) =>
normalized.deny.includes(pluginId) || normalized.entries[pluginId]?.enabled === false,
)
) {
return false;
}
for (const pluginId of pluginIds) {
if (
resolveEffectiveEnableState({
id: pluginId,
origin: "bundled",
config: normalized,
rootConfig: params.config,
}).enabled
) {
return true;
}
}
if (isBridgeChannelEnabledByConfig(params)) {
return true;
}
return false;
}
function replacePluginIdInList(
entries: string[] | undefined,
fromId: string,
@@ -664,8 +816,10 @@ export async function syncPluginsForUpdateChannel(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
logger?: PluginUpdateLogger;
externalizedBundledPluginBridges?: readonly ExternalizedBundledPluginBridge[];
}): Promise<PluginChannelSyncResult> {
const env = params.env ?? process.env;
const logger = params.logger ?? {};
const summary: PluginChannelSyncSummary = {
switchedToBundled: [],
switchedToNpm: [],
@@ -676,13 +830,10 @@ export async function syncPluginsForUpdateChannel(params: {
workspaceDir: params.workspaceDir,
env,
});
if (bundled.size === 0) {
return { config: params.config, changed: false, summary };
}
let next = params.config;
const loadHelpers = buildLoadPathHelpers(next.plugins?.load?.paths ?? [], env);
const installs = next.plugins?.installs ?? {};
let installs = next.plugins?.installs ?? {};
let changed = false;
if (params.channel === "dev") {
@@ -712,6 +863,105 @@ export async function syncPluginsForUpdateChannel(params: {
changed = true;
}
} else {
const bridges =
params.externalizedBundledPluginBridges ?? listExternalizedBundledPluginBridges();
for (const bridge of bridges) {
const targetPluginId = getExternalizedBundledPluginTargetId(bridge);
const bundledInfo = bundled.get(bridge.bundledPluginId);
if (bundledInfo) {
continue;
}
const existing = resolveBridgeInstallRecord({ installs, bridge });
if (
!existing &&
!isExternalizedBundledPluginEnabled({
config: next,
bridge,
})
) {
continue;
}
if (
existing &&
!isExternalizedBundledPluginEnabled({
config: next,
bridge,
})
) {
continue;
}
if (existing?.record.source === "npm" && existing.record.spec === bridge.npmSpec) {
if (existing.pluginId !== targetPluginId) {
next = migratePluginConfigId(next, existing.pluginId, targetPluginId);
installs = next.plugins?.installs ?? {};
changed = true;
}
if (bundledInfo?.localPath) {
loadHelpers.removePath(bundledInfo.localPath);
}
removeBridgeBundledLoadPaths({ bridge, loadPaths: loadHelpers, env });
continue;
}
if (
existing &&
!isBridgeBundledPathRecord({
bridge,
bundledLocalPath: bundledInfo?.localPath,
record: existing.record,
env,
})
) {
continue;
}
const result = await installPluginFromNpmSpec({
spec: bridge.npmSpec,
mode: "update",
expectedPluginId: targetPluginId,
logger,
});
if (!result.ok) {
const message = formatNpmInstallFailure({
pluginId: targetPluginId,
spec: bridge.npmSpec,
phase: "update",
result,
});
summary.errors.push(message);
logger.error?.(message);
continue;
}
const resolvedPluginId = result.pluginId;
if (existing && existing.pluginId !== resolvedPluginId) {
next = migratePluginConfigId(next, existing.pluginId, resolvedPluginId);
}
const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir));
next = recordPluginInstall(next, {
pluginId: resolvedPluginId,
source: "npm",
spec: bridge.npmSpec,
installPath: result.targetDir,
version: nextVersion,
...buildNpmResolutionInstallFields(result.npmResolution),
});
installs = next.plugins?.installs ?? {};
if (bundledInfo?.localPath) {
loadHelpers.removePath(bundledInfo.localPath);
}
if (existing?.record.sourcePath) {
loadHelpers.removePath(existing.record.sourcePath);
}
if (existing?.record.installPath) {
loadHelpers.removePath(existing.record.installPath);
}
removeBridgeBundledLoadPaths({ bridge, loadPaths: loadHelpers, env });
summary.switchedToNpm.push(resolvedPluginId);
changed = true;
}
for (const [pluginId, record] of Object.entries(installs)) {
const bundledInfo = bundled.get(pluginId);
if (!bundledInfo) {