diff --git a/src/plugins/externalized-bundled-plugins.ts b/src/plugins/externalized-bundled-plugins.ts new file mode 100644 index 00000000000..6b8d971904f --- /dev/null +++ b/src/plugins/externalized-bundled-plugins.ts @@ -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; +} diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index b9502fe79fc..4b277150263 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -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", + }); + }); }); diff --git a/src/plugins/update.ts b/src/plugins/update.ts index c2662aa5382..8dbd5a5fe9d 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -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; + 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; + 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)[channelId]; + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + continue; + } + if ((entry as Record).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 { 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) {