diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f69192e774..209aa9ffe13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai - Control UI/chat: keep optimistic user message cards visible during active sends by deferring same-session history reloads until the active run ends, including aborted and errored runs. (#66997) Thanks @scotthuang and @vincentkoc. - Media/Slack: allow host-local CSV and Markdown uploads only when the fallback buffer actually decodes as text, so real plain-text files work without letting opaque non-text blobs renamed to `.csv` or `.md` slip past the host-read guard. (#67047) Thanks @Unayung. - Ollama/onboarding: split setup into `Cloud + Local`, `Cloud only`, and `Local only`, support direct `OLLAMA_API_KEY` cloud setup without a local daemon, and keep Ollama web search on the local-host path. (#67005) Thanks @obviyus. +- Plugins/bundled channels: partition bundled channel lazy caches by active bundled root so `OPENCLAW_BUNDLED_PLUGINS_DIR` flips stop reusing stale plugin, setup, secrets, and runtime state. (#67200) Thanks @gumadeiras. ## 2026.4.14 diff --git a/src/channels/plugins/bundled.shape-guard.test.ts b/src/channels/plugins/bundled.shape-guard.test.ts index 3235457d3a2..bf84c28026c 100644 --- a/src/channels/plugins/bundled.shape-guard.test.ts +++ b/src/channels/plugins/bundled.shape-guard.test.ts @@ -165,6 +165,147 @@ describe("bundled channel entry shape guards", () => { } }); + it("partitions bundled channel lazy caches by active bundled root without re-importing", async () => { + const rootA = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-root-a-")); + const rootB = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-root-b-")); + const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + const testGlobal = globalThis as typeof globalThis & { + __bundledRootRuntime?: unknown; + }; + + const writeBundledRoot = (rootDir: string, label: string) => { + const pluginDir = path.join(rootDir, "dist", "extensions", "alpha"); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "index.js"), + [ + `globalThis.__bundledRootRuntime = globalThis.__bundledRootRuntime ?? [];`, + "export default {", + " kind: 'bundled-channel-entry',", + " id: 'alpha',", + ` name: ${JSON.stringify(`Alpha ${label}`)},`, + ` description: ${JSON.stringify(`Alpha ${label}`)},`, + " register() {},", + " loadChannelPlugin() {", + " return {", + " id: 'alpha',", + ` meta: { id: 'alpha', label: ${JSON.stringify(`Alpha ${label}`)} },`, + " capabilities: {},", + " config: {},", + ` secrets: { secretTargetRegistryEntries: [{ id: ${JSON.stringify(`channels.alpha.${label}.token`)}, targetType: 'channel' }] },`, + " };", + " },", + " loadChannelSecrets() {", + ` return { secretTargetRegistryEntries: [{ id: ${JSON.stringify(`channels.alpha.${label}.entry-token`)}, targetType: 'channel' }] };`, + " },", + " setChannelRuntime(runtime) {", + ` globalThis.__bundledRootRuntime.push(${JSON.stringify(`entry:${label}`)} + ':' + String(runtime.marker));`, + " },", + "};", + "", + ].join("\n"), + "utf8", + ); + fs.writeFileSync( + path.join(pluginDir, "setup-entry.js"), + [ + "export default {", + " kind: 'bundled-channel-setup-entry',", + " loadSetupPlugin() {", + " return {", + " id: 'alpha',", + ` meta: { id: 'alpha', label: ${JSON.stringify(`Setup ${label}`)} },`, + " capabilities: {},", + " config: {},", + ` secrets: { secretTargetRegistryEntries: [{ id: ${JSON.stringify(`channels.alpha.${label}.setup-plugin-token`)}, targetType: 'channel' }] },`, + " };", + " },", + " loadSetupSecrets() {", + ` return { secretTargetRegistryEntries: [{ id: ${JSON.stringify(`channels.alpha.${label}.setup-entry-token`)}, targetType: 'channel' }] };`, + " },", + "};", + "", + ].join("\n"), + "utf8", + ); + }; + + writeBundledRoot(rootA, "A"); + writeBundledRoot(rootB, "B"); + + vi.doMock("../../plugins/bundled-channel-runtime.js", () => ({ + listBundledChannelPluginMetadata: () => [ + { + dirName: "alpha", + manifest: { + id: "alpha", + channels: ["alpha"], + }, + source: { + source: "./index.js", + built: "./index.js", + }, + setupSource: { + source: "./setup-entry.js", + built: "./setup-entry.js", + }, + }, + ], + resolveBundledChannelGeneratedPath: ( + rootDir: string, + entry: { built?: string; source?: string }, + pluginDirName?: string, + ) => + path.join( + rootDir, + "dist", + "extensions", + pluginDirName ?? "alpha", + (entry.built ?? entry.source ?? "./index.js").replace(/^\.\//u, ""), + ), + })); + + try { + const bundled = await importFreshModule( + import.meta.url, + "./bundled.js?scope=bundled-root-partition", + ); + + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(rootA, "dist", "extensions"); + expect(bundled.requireBundledChannelPlugin("alpha").meta.label).toBe("Alpha A"); + expect(bundled.getBundledChannelSetupPlugin("alpha")?.meta.label).toBe("Setup A"); + expect(bundled.getBundledChannelSecrets("alpha")?.secretTargetRegistryEntries?.[0]?.id).toBe( + "channels.alpha.A.entry-token", + ); + expect( + bundled.getBundledChannelSetupSecrets("alpha")?.secretTargetRegistryEntries?.[0]?.id, + ).toBe("channels.alpha.A.setup-entry-token"); + bundled.setBundledChannelRuntime("alpha", { marker: "first" } as never); + + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(rootB, "dist", "extensions"); + expect(bundled.requireBundledChannelPlugin("alpha").meta.label).toBe("Alpha B"); + expect(bundled.getBundledChannelSetupPlugin("alpha")?.meta.label).toBe("Setup B"); + expect(bundled.getBundledChannelSecrets("alpha")?.secretTargetRegistryEntries?.[0]?.id).toBe( + "channels.alpha.B.entry-token", + ); + expect( + bundled.getBundledChannelSetupSecrets("alpha")?.secretTargetRegistryEntries?.[0]?.id, + ).toBe("channels.alpha.B.setup-entry-token"); + bundled.setBundledChannelRuntime("alpha", { marker: "second" } as never); + + expect(testGlobal.__bundledRootRuntime).toEqual(["entry:A:first", "entry:B:second"]); + } finally { + if (previousBundledPluginsDir === undefined) { + delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + } else { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = previousBundledPluginsDir; + } + fs.rmSync(rootA, { recursive: true, force: true }); + fs.rmSync(rootB, { recursive: true, force: true }); + delete testGlobal.__bundledRootRuntime; + } + }); + it("keeps channel entrypoints on the dedicated entry-contract SDK surface", () => { const offenders: string[] = []; diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index 7ec03713633..b576eea65c4 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -42,6 +42,17 @@ type GeneratedBundledChannelEntry = { setupEntry?: BundledChannelSetupEntryRuntimeContract; }; +type BundledChannelCacheContext = { + pluginLoadInProgressIds: Set; + setupPluginLoadInProgressIds: Set; + entryLoadInProgressIds: Set; + lazyEntriesById: Map; + lazyPluginsById: Map; + lazySetupPluginsById: Map; + lazySecretsById: Map; + lazySetupSecretsById: Map; +}; + const log = createSubsystemLogger("channels"); const OPENCLAW_PACKAGE_ROOT = resolveOpenClawPackageRootSync({ @@ -122,27 +133,27 @@ function hasSetupEntryFeature( } function resolveBundledChannelBoundaryRoot(params: { + packageRoot: string; metadata: BundledChannelPluginMetadata; modulePath: string; }): string { - const packageRoot = resolveBundledChannelPackageRoot(); - const distRoot = path.resolve(packageRoot, "dist", "extensions", params.metadata.dirName); + const distRoot = path.resolve(params.packageRoot, "dist", "extensions", params.metadata.dirName); if (params.modulePath === distRoot || params.modulePath.startsWith(`${distRoot}${path.sep}`)) { return distRoot; } - return path.resolve(packageRoot, "extensions", params.metadata.dirName); + return path.resolve(params.packageRoot, "extensions", params.metadata.dirName); } function resolveGeneratedBundledChannelModulePath(params: { + packageRoot: string; metadata: BundledChannelPluginMetadata; entry: BundledChannelPluginMetadata["source"] | BundledChannelPluginMetadata["setupSource"]; }): string | null { if (!params.entry) { return null; } - const packageRoot = resolveBundledChannelPackageRoot(); const resolved = resolveBundledChannelGeneratedPath( - packageRoot, + params.packageRoot, params.entry, params.metadata.dirName, ); @@ -153,6 +164,7 @@ function resolveGeneratedBundledChannelModulePath(params: { } function loadGeneratedBundledChannelModule(params: { + packageRoot: string; metadata: BundledChannelPluginMetadata; entry: BundledChannelPluginMetadata["source"] | BundledChannelPluginMetadata["setupSource"]; }): unknown { @@ -163,10 +175,12 @@ function loadGeneratedBundledChannelModule(params: { return loadChannelPluginModule({ modulePath, rootDir: resolveBundledChannelBoundaryRoot({ + packageRoot: params.packageRoot, metadata: params.metadata, modulePath, }), boundaryRootDir: resolveBundledChannelBoundaryRoot({ + packageRoot: params.packageRoot, metadata: params.metadata, modulePath, }), @@ -176,12 +190,14 @@ function loadGeneratedBundledChannelModule(params: { } function loadGeneratedBundledChannelEntry(params: { + packageRoot: string; metadata: BundledChannelPluginMetadata; includeSetup: boolean; }): GeneratedBundledChannelEntry | null { try { const entry = resolveChannelPluginModuleEntry( loadGeneratedBundledChannelModule({ + packageRoot: params.packageRoot, metadata: params.metadata, entry: params.metadata.source, }), @@ -196,6 +212,7 @@ function loadGeneratedBundledChannelEntry(params: { params.includeSetup && params.metadata.setupSource ? resolveChannelSetupModuleEntry( loadGeneratedBundledChannelModule({ + packageRoot: params.packageRoot, metadata: params.metadata, entry: params.metadata.setupSource, }), @@ -214,9 +231,30 @@ function loadGeneratedBundledChannelEntry(params: { } const cachedBundledChannelMetadata = new Map(); +const bundledChannelCacheContexts = new Map(); -function listBundledChannelMetadata(): readonly BundledChannelPluginMetadata[] { - const packageRoot = resolveBundledChannelPackageRoot(); +function getBundledChannelCacheContext(packageRoot: string): BundledChannelCacheContext { + const cached = bundledChannelCacheContexts.get(packageRoot); + if (cached) { + return cached; + } + const created: BundledChannelCacheContext = { + pluginLoadInProgressIds: new Set(), + setupPluginLoadInProgressIds: new Set(), + entryLoadInProgressIds: new Set(), + lazyEntriesById: new Map(), + lazyPluginsById: new Map(), + lazySetupPluginsById: new Map(), + lazySecretsById: new Map(), + lazySetupSecretsById: new Map(), + }; + bundledChannelCacheContexts.set(packageRoot, created); + return created; +} + +function listBundledChannelMetadata( + packageRoot = resolveBundledChannelPackageRoot(), +): readonly BundledChannelPluginMetadata[] { const cached = cachedBundledChannelMetadata.get(packageRoot); if (cached) { return cached; @@ -230,72 +268,171 @@ function listBundledChannelMetadata(): readonly BundledChannelPluginMetadata[] { return loaded; } -export function listBundledChannelPluginIds(): readonly ChannelId[] { - return listBundledChannelMetadata() +function listBundledChannelPluginIdsForRoot(packageRoot: string): readonly ChannelId[] { + return listBundledChannelMetadata(packageRoot) .map((metadata) => metadata.manifest.id) .toSorted((left, right) => left.localeCompare(right)); } -const pluginLoadInProgressIds = new Set(); -const setupPluginLoadInProgressIds = new Set(); -const entryLoadInProgressIds = new Set(); -const lazyEntriesById = new Map(); -const lazyPluginsById = new Map(); -const lazySetupPluginsById = new Map(); -const lazySecretsById = new Map(); -const lazySetupSecretsById = new Map(); +export function listBundledChannelPluginIds(): readonly ChannelId[] { + return listBundledChannelPluginIdsForRoot(resolveBundledChannelPackageRoot()); +} -function resolveBundledChannelMetadata(id: ChannelId): BundledChannelPluginMetadata | undefined { - return listBundledChannelMetadata().find( +function resolveBundledChannelMetadata( + id: ChannelId, + packageRoot: string, +): BundledChannelPluginMetadata | undefined { + return listBundledChannelMetadata(packageRoot).find( (metadata) => metadata.manifest.id === id || metadata.manifest.channels?.includes(id), ); } -function getLazyGeneratedBundledChannelEntry( +function getLazyGeneratedBundledChannelEntryForRoot( id: ChannelId, + packageRoot: string, + cacheContext: BundledChannelCacheContext, params?: { includeSetup?: boolean }, ): GeneratedBundledChannelEntry | null { - const cached = lazyEntriesById.get(id); + const cached = cacheContext.lazyEntriesById.get(id); if (cached && (!params?.includeSetup || cached.setupEntry)) { return cached; } if (cached === null && !params?.includeSetup) { return null; } - const metadata = resolveBundledChannelMetadata(id); + const metadata = resolveBundledChannelMetadata(id, packageRoot); if (!metadata) { - lazyEntriesById.set(id, null); + cacheContext.lazyEntriesById.set(id, null); return null; } - if (entryLoadInProgressIds.has(id)) { + if (cacheContext.entryLoadInProgressIds.has(id)) { return null; } - entryLoadInProgressIds.add(id); + cacheContext.entryLoadInProgressIds.add(id); try { const entry = loadGeneratedBundledChannelEntry({ + packageRoot, metadata, includeSetup: params?.includeSetup === true, }); - lazyEntriesById.set(id, entry); + cacheContext.lazyEntriesById.set(id, entry); if (entry?.entry.id && entry.entry.id !== id) { - lazyEntriesById.set(entry.entry.id, entry); + cacheContext.lazyEntriesById.set(entry.entry.id, entry); } return entry; } finally { - entryLoadInProgressIds.delete(id); + cacheContext.entryLoadInProgressIds.delete(id); } } +function getBundledChannelPluginForRoot( + id: ChannelId, + packageRoot: string, + cacheContext: BundledChannelCacheContext, +): ChannelPlugin | undefined { + const cached = cacheContext.lazyPluginsById.get(id); + if (cached) { + return cached; + } + if (cacheContext.pluginLoadInProgressIds.has(id)) { + return undefined; + } + const entry = getLazyGeneratedBundledChannelEntryForRoot(id, packageRoot, cacheContext)?.entry; + if (!entry) { + return undefined; + } + cacheContext.pluginLoadInProgressIds.add(id); + try { + const plugin = entry.loadChannelPlugin(); + cacheContext.lazyPluginsById.set(id, plugin); + return plugin; + } finally { + cacheContext.pluginLoadInProgressIds.delete(id); + } +} + +function getBundledChannelSecretsForRoot( + id: ChannelId, + packageRoot: string, + cacheContext: BundledChannelCacheContext, +): ChannelPlugin["secrets"] | undefined { + if (cacheContext.lazySecretsById.has(id)) { + return cacheContext.lazySecretsById.get(id) ?? undefined; + } + const entry = getLazyGeneratedBundledChannelEntryForRoot(id, packageRoot, cacheContext)?.entry; + if (!entry) { + return undefined; + } + const secrets = + entry.loadChannelSecrets?.() ?? + getBundledChannelPluginForRoot(id, packageRoot, cacheContext)?.secrets; + cacheContext.lazySecretsById.set(id, secrets ?? null); + return secrets; +} + +function getBundledChannelSetupPluginForRoot( + id: ChannelId, + packageRoot: string, + cacheContext: BundledChannelCacheContext, +): ChannelPlugin | undefined { + const cached = cacheContext.lazySetupPluginsById.get(id); + if (cached) { + return cached; + } + if (cacheContext.setupPluginLoadInProgressIds.has(id)) { + return undefined; + } + const entry = getLazyGeneratedBundledChannelEntryForRoot(id, packageRoot, cacheContext, { + includeSetup: true, + })?.setupEntry; + if (!entry) { + return undefined; + } + cacheContext.setupPluginLoadInProgressIds.add(id); + try { + const plugin = entry.loadSetupPlugin(); + cacheContext.lazySetupPluginsById.set(id, plugin); + return plugin; + } finally { + cacheContext.setupPluginLoadInProgressIds.delete(id); + } +} + +function getBundledChannelSetupSecretsForRoot( + id: ChannelId, + packageRoot: string, + cacheContext: BundledChannelCacheContext, +): ChannelPlugin["secrets"] | undefined { + if (cacheContext.lazySetupSecretsById.has(id)) { + return cacheContext.lazySetupSecretsById.get(id) ?? undefined; + } + const entry = getLazyGeneratedBundledChannelEntryForRoot(id, packageRoot, cacheContext, { + includeSetup: true, + })?.setupEntry; + if (!entry) { + return undefined; + } + const secrets = + entry.loadSetupSecrets?.() ?? + getBundledChannelSetupPluginForRoot(id, packageRoot, cacheContext)?.secrets; + cacheContext.lazySetupSecretsById.set(id, secrets ?? null); + return secrets; +} + export function listBundledChannelPlugins(): readonly ChannelPlugin[] { - return listBundledChannelPluginIds().flatMap((id) => { - const plugin = getBundledChannelPlugin(id); + const packageRoot = resolveBundledChannelPackageRoot(); + const cacheContext = getBundledChannelCacheContext(packageRoot); + return listBundledChannelPluginIdsForRoot(packageRoot).flatMap((id) => { + const plugin = getBundledChannelPluginForRoot(id, packageRoot, cacheContext); return plugin ? [plugin] : []; }); } export function listBundledChannelSetupPlugins(): readonly ChannelPlugin[] { - return listBundledChannelPluginIds().flatMap((id) => { - const plugin = getBundledChannelSetupPlugin(id); + const packageRoot = resolveBundledChannelPackageRoot(); + const cacheContext = getBundledChannelCacheContext(packageRoot); + return listBundledChannelPluginIdsForRoot(packageRoot).flatMap((id) => { + const plugin = getBundledChannelSetupPluginForRoot(id, packageRoot, cacheContext); return plugin ? [plugin] : []; }); } @@ -303,84 +440,54 @@ export function listBundledChannelSetupPlugins(): readonly ChannelPlugin[] { export function listBundledChannelSetupPluginsByFeature( feature: keyof NonNullable, ): readonly ChannelPlugin[] { - return listBundledChannelPluginIds().flatMap((id) => { - const setupEntry = getLazyGeneratedBundledChannelEntry(id, { includeSetup: true })?.setupEntry; + const packageRoot = resolveBundledChannelPackageRoot(); + const cacheContext = getBundledChannelCacheContext(packageRoot); + return listBundledChannelPluginIdsForRoot(packageRoot).flatMap((id) => { + const setupEntry = getLazyGeneratedBundledChannelEntryForRoot(id, packageRoot, cacheContext, { + includeSetup: true, + })?.setupEntry; if (!hasSetupEntryFeature(setupEntry, feature)) { return []; } - const plugin = getBundledChannelSetupPlugin(id); + const plugin = getBundledChannelSetupPluginForRoot(id, packageRoot, cacheContext); return plugin ? [plugin] : []; }); } export function getBundledChannelPlugin(id: ChannelId): ChannelPlugin | undefined { - const cached = lazyPluginsById.get(id); - if (cached) { - return cached; - } - if (pluginLoadInProgressIds.has(id)) { - return undefined; - } - const entry = getLazyGeneratedBundledChannelEntry(id)?.entry; - if (!entry) { - return undefined; - } - pluginLoadInProgressIds.add(id); - try { - const plugin = entry.loadChannelPlugin(); - lazyPluginsById.set(id, plugin); - return plugin; - } finally { - pluginLoadInProgressIds.delete(id); - } + const packageRoot = resolveBundledChannelPackageRoot(); + return getBundledChannelPluginForRoot( + id, + packageRoot, + getBundledChannelCacheContext(packageRoot), + ); } export function getBundledChannelSecrets(id: ChannelId): ChannelPlugin["secrets"] | undefined { - if (lazySecretsById.has(id)) { - return lazySecretsById.get(id) ?? undefined; - } - const entry = getLazyGeneratedBundledChannelEntry(id)?.entry; - if (!entry) { - return undefined; - } - const secrets = entry.loadChannelSecrets?.() ?? getBundledChannelPlugin(id)?.secrets; - lazySecretsById.set(id, secrets ?? null); - return secrets; + const packageRoot = resolveBundledChannelPackageRoot(); + return getBundledChannelSecretsForRoot( + id, + packageRoot, + getBundledChannelCacheContext(packageRoot), + ); } export function getBundledChannelSetupPlugin(id: ChannelId): ChannelPlugin | undefined { - const cached = lazySetupPluginsById.get(id); - if (cached) { - return cached; - } - if (setupPluginLoadInProgressIds.has(id)) { - return undefined; - } - const entry = getLazyGeneratedBundledChannelEntry(id, { includeSetup: true })?.setupEntry; - if (!entry) { - return undefined; - } - setupPluginLoadInProgressIds.add(id); - try { - const plugin = entry.loadSetupPlugin(); - lazySetupPluginsById.set(id, plugin); - return plugin; - } finally { - setupPluginLoadInProgressIds.delete(id); - } + const packageRoot = resolveBundledChannelPackageRoot(); + return getBundledChannelSetupPluginForRoot( + id, + packageRoot, + getBundledChannelCacheContext(packageRoot), + ); } export function getBundledChannelSetupSecrets(id: ChannelId): ChannelPlugin["secrets"] | undefined { - if (lazySetupSecretsById.has(id)) { - return lazySetupSecretsById.get(id) ?? undefined; - } - const entry = getLazyGeneratedBundledChannelEntry(id, { includeSetup: true })?.setupEntry; - if (!entry) { - return undefined; - } - const secrets = entry.loadSetupSecrets?.() ?? getBundledChannelSetupPlugin(id)?.secrets; - lazySetupSecretsById.set(id, secrets ?? null); - return secrets; + const packageRoot = resolveBundledChannelPackageRoot(); + return getBundledChannelSetupSecretsForRoot( + id, + packageRoot, + getBundledChannelCacheContext(packageRoot), + ); } export function requireBundledChannelPlugin(id: ChannelId): ChannelPlugin { @@ -392,7 +499,12 @@ export function requireBundledChannelPlugin(id: ChannelId): ChannelPlugin { } export function setBundledChannelRuntime(id: ChannelId, runtime: PluginRuntime): void { - const setter = getLazyGeneratedBundledChannelEntry(id)?.entry.setChannelRuntime; + const packageRoot = resolveBundledChannelPackageRoot(); + const setter = getLazyGeneratedBundledChannelEntryForRoot( + id, + packageRoot, + getBundledChannelCacheContext(packageRoot), + )?.entry.setChannelRuntime; if (!setter) { throw new Error(`missing bundled channel runtime setter: ${id}`); }