From 6aeef1ff9409a6286705ef7bfd5fd804802c6f29 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 15 Apr 2026 12:17:42 -0400 Subject: [PATCH] refactor(channels): simplify bundled root scope helpers --- src/channels/plugins/bootstrap-registry.ts | 16 +- src/channels/plugins/bundled-ids.ts | 4 +- .../plugins/bundled-root-caches.test.ts | 6 +- src/channels/plugins/bundled-root.ts | 35 +++-- .../plugins/bundled.shape-guard.test.ts | 91 +++++++++++ src/channels/plugins/bundled.ts | 142 ++++++++++-------- src/plugins/bundled-channel-runtime.ts | 5 +- src/plugins/bundled-plugin-metadata.test.ts | 39 +++++ src/plugins/bundled-plugin-metadata.ts | 95 +++++++++--- 9 files changed, 327 insertions(+), 106 deletions(-) diff --git a/src/channels/plugins/bootstrap-registry.ts b/src/channels/plugins/bootstrap-registry.ts index 410feab6d39..93cf31d5431 100644 --- a/src/channels/plugins/bootstrap-registry.ts +++ b/src/channels/plugins/bootstrap-registry.ts @@ -1,6 +1,6 @@ import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { listBundledChannelPluginIdsForRoot } from "./bundled-ids.js"; -import { resolveBundledChannelPackageRoot } from "./bundled-root.js"; +import { resolveBundledChannelRootScope } from "./bundled-root.js"; import { getBundledChannelPlugin, getBundledChannelSecrets, @@ -69,11 +69,11 @@ function mergeBootstrapPlugin( } function buildBootstrapPlugins( - packageRoot: string, + cacheKey: string, env: NodeJS.ProcessEnv = process.env, ): CachedBootstrapPlugins { return { - sortedIds: listBundledChannelPluginIdsForRoot(packageRoot, env), + sortedIds: listBundledChannelPluginIdsForRoot(cacheKey, env), byId: new Map(), secretsById: new Map(), missingIds: new Set(), @@ -81,20 +81,20 @@ function buildBootstrapPlugins( } function getBootstrapPlugins( - packageRoot = resolveBundledChannelPackageRoot(), + cacheKey = resolveBundledChannelRootScope().cacheKey, env: NodeJS.ProcessEnv = process.env, ): CachedBootstrapPlugins { - const cached = cachedBootstrapPluginsByRoot.get(packageRoot); + const cached = cachedBootstrapPluginsByRoot.get(cacheKey); if (cached) { return cached; } - const created = buildBootstrapPlugins(packageRoot, env); - cachedBootstrapPluginsByRoot.set(packageRoot, created); + const created = buildBootstrapPlugins(cacheKey, env); + cachedBootstrapPluginsByRoot.set(cacheKey, created); return created; } function resolveActiveBootstrapPlugins(): CachedBootstrapPlugins { - return getBootstrapPlugins(resolveBundledChannelPackageRoot()); + return getBootstrapPlugins(resolveBundledChannelRootScope().cacheKey); } export function listBootstrapChannelPluginIds(): readonly string[] { diff --git a/src/channels/plugins/bundled-ids.ts b/src/channels/plugins/bundled-ids.ts index 1cecd28aa74..f801856179b 100644 --- a/src/channels/plugins/bundled-ids.ts +++ b/src/channels/plugins/bundled-ids.ts @@ -1,5 +1,5 @@ import { listChannelCatalogEntries } from "../../plugins/channel-catalog-registry.js"; -import { resolveBundledChannelPackageRoot } from "./bundled-root.js"; +import { resolveBundledChannelRootScope } from "./bundled-root.js"; const bundledChannelPluginIdsByRoot = new Map(); @@ -19,5 +19,5 @@ export function listBundledChannelPluginIdsForRoot( } export function listBundledChannelPluginIds(): string[] { - return listBundledChannelPluginIdsForRoot(resolveBundledChannelPackageRoot()); + return listBundledChannelPluginIdsForRoot(resolveBundledChannelRootScope().cacheKey); } diff --git a/src/channels/plugins/bundled-root-caches.test.ts b/src/channels/plugins/bundled-root-caches.test.ts index 0ca4e145600..704d3a1255e 100644 --- a/src/channels/plugins/bundled-root-caches.test.ts +++ b/src/channels/plugins/bundled-root-caches.test.ts @@ -79,11 +79,11 @@ describe("bundled root-aware caches", () => { const rootB = makeBundledRoot("openclaw-bootstrap-b-"); vi.doMock("./bundled-ids.js", () => ({ - listBundledChannelPluginIdsForRoot: (packageRoot: string) => { - if (packageRoot === rootA.root) { + listBundledChannelPluginIdsForRoot: (cacheKey: string) => { + if (cacheKey === rootA.pluginsDir) { return ["alpha"]; } - if (packageRoot === rootB.root) { + if (cacheKey === rootB.pluginsDir) { return ["beta"]; } return []; diff --git a/src/channels/plugins/bundled-root.ts b/src/channels/plugins/bundled-root.ts index 5267b1d2b5b..0e6002b9ba0 100644 --- a/src/channels/plugins/bundled-root.ts +++ b/src/channels/plugins/bundled-root.ts @@ -13,12 +13,14 @@ const OPENCLAW_PACKAGE_ROOT = ? path.resolve(fileURLToPath(new URL("../../..", import.meta.url))) : process.cwd()); -export function derivePackageRootFromBundledPluginsDir(pluginsDir: string): string { - const resolvedDir = path.resolve(pluginsDir); - if (path.basename(resolvedDir) !== "extensions") { - return resolvedDir; - } - const parentDir = path.dirname(resolvedDir); +export type BundledChannelRootScope = { + packageRoot: string; + cacheKey: string; + pluginsDir?: string; +}; + +function derivePackageRootFromExtensionsDir(extensionsDir: string): string { + const parentDir = path.dirname(extensionsDir); const parentBase = path.basename(parentDir); if (parentBase === "dist" || parentBase === "dist-runtime") { return path.dirname(parentDir); @@ -26,10 +28,23 @@ export function derivePackageRootFromBundledPluginsDir(pluginsDir: string): stri return parentDir; } -export function resolveBundledChannelPackageRoot(env: NodeJS.ProcessEnv = process.env): string { +export function resolveBundledChannelRootScope( + env: NodeJS.ProcessEnv = process.env, +): BundledChannelRootScope { const bundledPluginsDir = resolveBundledPluginsDir(env); - if (bundledPluginsDir) { - return derivePackageRootFromBundledPluginsDir(bundledPluginsDir); + if (!bundledPluginsDir) { + return { + packageRoot: OPENCLAW_PACKAGE_ROOT, + cacheKey: OPENCLAW_PACKAGE_ROOT, + }; } - return OPENCLAW_PACKAGE_ROOT; + const resolvedPluginsDir = path.resolve(bundledPluginsDir); + return { + packageRoot: + path.basename(resolvedPluginsDir) === "extensions" + ? derivePackageRootFromExtensionsDir(resolvedPluginsDir) + : resolvedPluginsDir, + cacheKey: resolvedPluginsDir, + pluginsDir: resolvedPluginsDir, + }; } diff --git a/src/channels/plugins/bundled.shape-guard.test.ts b/src/channels/plugins/bundled.shape-guard.test.ts index bf84c28026c..19f465ce5de 100644 --- a/src/channels/plugins/bundled.shape-guard.test.ts +++ b/src/channels/plugins/bundled.shape-guard.test.ts @@ -165,6 +165,97 @@ describe("bundled channel entry shape guards", () => { } }); + it("treats direct bundled plugin-tree overrides as scan roots", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-direct-override-")); + const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + const pluginsRoot = path.join(tempRoot, "bundled-plugins"); + const pluginDir = path.join(pluginsRoot, "alpha"); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "index.js"), + [ + "globalThis.__bundledOverrideRuntime = undefined;", + "const plugin = { id: 'alpha', meta: {}, capabilities: {}, config: {} };", + "export default {", + " kind: 'bundled-channel-entry',", + " id: 'alpha',", + " name: 'Alpha',", + " description: 'Alpha',", + " register() {},", + " loadChannelPlugin() { return plugin; },", + " setChannelRuntime(runtime) { globalThis.__bundledOverrideRuntime = runtime.marker; },", + "};", + "", + ].join("\n"), + "utf8", + ); + + let metadataScanDir: string | undefined; + let generatedRootDir: string | undefined; + let generatedScanDir: string | undefined; + + vi.doMock("../../plugins/bundled-channel-runtime.js", () => ({ + listBundledChannelPluginMetadata: (params?: { rootDir?: string; scanDir?: string }) => { + metadataScanDir = params?.scanDir; + return [ + { + dirName: "alpha", + manifest: { + id: "alpha", + channels: ["alpha"], + }, + source: { + source: "./index.js", + built: "./index.js", + }, + }, + ]; + }, + resolveBundledChannelGeneratedPath: ( + rootDir: string, + entry: { built?: string; source?: string }, + pluginDirName?: string, + scanDir?: string, + ) => { + generatedRootDir = rootDir; + generatedScanDir = scanDir; + return path.join( + scanDir ?? path.join(rootDir, "dist", "extensions"), + pluginDirName ?? "alpha", + (entry.built ?? entry.source ?? "./index.js").replace(/^\.\//u, ""), + ); + }, + })); + + try { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = pluginsRoot; + + const bundled = await importFreshModule( + import.meta.url, + "./bundled.js?scope=bundled-direct-override-root", + ); + + bundled.setBundledChannelRuntime("alpha", { marker: "ok" } as never); + const testGlobal = globalThis as typeof globalThis & { + __bundledOverrideRuntime?: unknown; + }; + + expect(metadataScanDir).toBe(pluginsRoot); + expect(generatedRootDir).toBe(pluginsRoot); + expect(generatedScanDir).toBe(pluginsRoot); + expect(testGlobal.__bundledOverrideRuntime).toBe("ok"); + expect(bundled.requireBundledChannelPlugin("alpha").id).toBe("alpha"); + } finally { + if (previousBundledPluginsDir === undefined) { + delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + } else { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = previousBundledPluginsDir; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + delete (globalThis as { __bundledOverrideRuntime?: unknown }).__bundledOverrideRuntime; + } + }); + 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-")); diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index 9e79c6bf118..e58bb22c446 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -8,7 +8,7 @@ import { } from "../../plugins/bundled-channel-runtime.js"; import { unwrapDefaultModuleExport } from "../../plugins/module-export.js"; import type { PluginRuntime } from "../../plugins/runtime/types.js"; -import { resolveBundledChannelPackageRoot } from "./bundled-root.js"; +import { resolveBundledChannelRootScope, type BundledChannelRootScope } from "./bundled-root.js"; import { isJavaScriptModulePath, loadChannelPluginModule } from "./module-loader.js"; import type { ChannelPlugin } from "./types.plugin.js"; import type { ChannelId } from "./types.public.js"; @@ -102,9 +102,20 @@ function hasSetupEntryFeature( function resolveBundledChannelBoundaryRoot(params: { packageRoot: string; + pluginsDir?: string; metadata: BundledChannelPluginMetadata; modulePath: string; }): string { + const overrideRoot = params.pluginsDir + ? path.resolve(params.pluginsDir, params.metadata.dirName) + : null; + if ( + overrideRoot && + (params.modulePath === overrideRoot || + params.modulePath.startsWith(`${overrideRoot}${path.sep}`)) + ) { + return overrideRoot; + } const distRoot = path.resolve(params.packageRoot, "dist", "extensions", params.metadata.dirName); if (params.modulePath === distRoot || params.modulePath.startsWith(`${distRoot}${path.sep}`)) { return distRoot; @@ -112,27 +123,28 @@ function resolveBundledChannelBoundaryRoot(params: { return path.resolve(params.packageRoot, "extensions", params.metadata.dirName); } +function resolveBundledChannelScanDir(rootScope: BundledChannelRootScope): string | undefined { + return rootScope.pluginsDir; +} + function resolveGeneratedBundledChannelModulePath(params: { - packageRoot: string; + rootScope: BundledChannelRootScope; metadata: BundledChannelPluginMetadata; entry: BundledChannelPluginMetadata["source"] | BundledChannelPluginMetadata["setupSource"]; }): string | null { if (!params.entry) { return null; } - const resolved = resolveBundledChannelGeneratedPath( - params.packageRoot, + return resolveBundledChannelGeneratedPath( + params.rootScope.packageRoot, params.entry, params.metadata.dirName, + resolveBundledChannelScanDir(params.rootScope), ); - if (resolved) { - return resolved; - } - return null; } function loadGeneratedBundledChannelModule(params: { - packageRoot: string; + rootScope: BundledChannelRootScope; metadata: BundledChannelPluginMetadata; entry: BundledChannelPluginMetadata["source"] | BundledChannelPluginMetadata["setupSource"]; }): unknown { @@ -140,8 +152,10 @@ function loadGeneratedBundledChannelModule(params: { if (!modulePath) { throw new Error(`missing generated module for bundled channel ${params.metadata.manifest.id}`); } + const scanDir = resolveBundledChannelScanDir(params.rootScope); const boundaryRoot = resolveBundledChannelBoundaryRoot({ - packageRoot: params.packageRoot, + packageRoot: params.rootScope.packageRoot, + ...(scanDir ? { pluginsDir: scanDir } : {}), metadata: params.metadata, modulePath, }); @@ -155,14 +169,14 @@ function loadGeneratedBundledChannelModule(params: { } function loadGeneratedBundledChannelEntry(params: { - packageRoot: string; + rootScope: BundledChannelRootScope; metadata: BundledChannelPluginMetadata; includeSetup: boolean; }): GeneratedBundledChannelEntry | null { try { const entry = resolveChannelPluginModuleEntry( loadGeneratedBundledChannelModule({ - packageRoot: params.packageRoot, + rootScope: params.rootScope, metadata: params.metadata, entry: params.metadata.source, }), @@ -177,7 +191,7 @@ function loadGeneratedBundledChannelEntry(params: { params.includeSetup && params.metadata.setupSource ? resolveChannelSetupModuleEntry( loadGeneratedBundledChannelModule({ - packageRoot: params.packageRoot, + rootScope: params.rootScope, metadata: params.metadata, entry: params.metadata.setupSource, }), @@ -211,65 +225,69 @@ function createBundledChannelCacheContext(): BundledChannelCacheContext { }; } -function getBundledChannelCacheContext(packageRoot: string): BundledChannelCacheContext { - const cached = bundledChannelCacheContexts.get(packageRoot); +function getBundledChannelCacheContext(cacheKey: string): BundledChannelCacheContext { + const cached = bundledChannelCacheContexts.get(cacheKey); if (cached) { return cached; } const created = createBundledChannelCacheContext(); - bundledChannelCacheContexts.set(packageRoot, created); + bundledChannelCacheContexts.set(cacheKey, created); return created; } function resolveActiveBundledChannelCacheScope(): { - packageRoot: string; + rootScope: BundledChannelRootScope; cacheContext: BundledChannelCacheContext; } { - const packageRoot = resolveBundledChannelPackageRoot(); + const rootScope = resolveBundledChannelRootScope(); return { - packageRoot, - cacheContext: getBundledChannelCacheContext(packageRoot), + rootScope, + cacheContext: getBundledChannelCacheContext(rootScope.cacheKey), }; } function listBundledChannelMetadata( - packageRoot = resolveBundledChannelPackageRoot(), + rootScope = resolveBundledChannelRootScope(), ): readonly BundledChannelPluginMetadata[] { - const cached = cachedBundledChannelMetadata.get(packageRoot); + const cached = cachedBundledChannelMetadata.get(rootScope.cacheKey); if (cached) { return cached; } + const scanDir = resolveBundledChannelScanDir(rootScope); const loaded = listBundledChannelPluginMetadata({ - rootDir: packageRoot, + rootDir: rootScope.packageRoot, + ...(scanDir ? { scanDir } : {}), includeChannelConfigs: false, includeSyntheticChannelConfigs: false, }).filter((metadata) => (metadata.manifest.channels?.length ?? 0) > 0); - cachedBundledChannelMetadata.set(packageRoot, loaded); + cachedBundledChannelMetadata.set(rootScope.cacheKey, loaded); return loaded; } -function listBundledChannelPluginIdsForRoot(packageRoot: string): readonly ChannelId[] { - return listBundledChannelMetadata(packageRoot) +function listBundledChannelPluginIdsForRoot( + rootScope: BundledChannelRootScope, +): readonly ChannelId[] { + return listBundledChannelMetadata(rootScope) .map((metadata) => metadata.manifest.id) .toSorted((left, right) => left.localeCompare(right)); } export function listBundledChannelPluginIds(): readonly ChannelId[] { - return listBundledChannelPluginIdsForRoot(resolveBundledChannelPackageRoot()); + return listBundledChannelPluginIdsForRoot(resolveBundledChannelRootScope()); } function resolveBundledChannelMetadata( id: ChannelId, - packageRoot: string, + rootScope: BundledChannelRootScope, ): BundledChannelPluginMetadata | undefined { - return listBundledChannelMetadata(packageRoot).find( + return listBundledChannelMetadata(rootScope).find( (metadata) => metadata.manifest.id === id || metadata.manifest.channels?.includes(id), ); } function getLazyGeneratedBundledChannelEntryForRoot( id: ChannelId, - packageRoot: string, + rootScope: BundledChannelRootScope, cacheContext: BundledChannelCacheContext, params?: { includeSetup?: boolean }, ): GeneratedBundledChannelEntry | null { @@ -280,7 +298,7 @@ function getLazyGeneratedBundledChannelEntryForRoot( if (cached === null && !params?.includeSetup) { return null; } - const metadata = resolveBundledChannelMetadata(id, packageRoot); + const metadata = resolveBundledChannelMetadata(id, rootScope); if (!metadata) { cacheContext.lazyEntriesById.set(id, null); return null; @@ -291,7 +309,7 @@ function getLazyGeneratedBundledChannelEntryForRoot( cacheContext.entryLoadInProgressIds.add(id); try { const entry = loadGeneratedBundledChannelEntry({ - packageRoot, + rootScope, metadata, includeSetup: params?.includeSetup === true, }); @@ -307,7 +325,7 @@ function getLazyGeneratedBundledChannelEntryForRoot( function getBundledChannelPluginForRoot( id: ChannelId, - packageRoot: string, + rootScope: BundledChannelRootScope, cacheContext: BundledChannelCacheContext, ): ChannelPlugin | undefined { const cached = cacheContext.lazyPluginsById.get(id); @@ -317,7 +335,7 @@ function getBundledChannelPluginForRoot( if (cacheContext.pluginLoadInProgressIds.has(id)) { return undefined; } - const entry = getLazyGeneratedBundledChannelEntryForRoot(id, packageRoot, cacheContext)?.entry; + const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext)?.entry; if (!entry) { return undefined; } @@ -333,26 +351,26 @@ function getBundledChannelPluginForRoot( function getBundledChannelSecretsForRoot( id: ChannelId, - packageRoot: string, + rootScope: BundledChannelRootScope, cacheContext: BundledChannelCacheContext, ): ChannelPlugin["secrets"] | undefined { if (cacheContext.lazySecretsById.has(id)) { return cacheContext.lazySecretsById.get(id) ?? undefined; } - const entry = getLazyGeneratedBundledChannelEntryForRoot(id, packageRoot, cacheContext)?.entry; + const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext)?.entry; if (!entry) { return undefined; } const secrets = entry.loadChannelSecrets?.() ?? - getBundledChannelPluginForRoot(id, packageRoot, cacheContext)?.secrets; + getBundledChannelPluginForRoot(id, rootScope, cacheContext)?.secrets; cacheContext.lazySecretsById.set(id, secrets ?? null); return secrets; } function getBundledChannelSetupPluginForRoot( id: ChannelId, - packageRoot: string, + rootScope: BundledChannelRootScope, cacheContext: BundledChannelCacheContext, ): ChannelPlugin | undefined { const cached = cacheContext.lazySetupPluginsById.get(id); @@ -362,7 +380,7 @@ function getBundledChannelSetupPluginForRoot( if (cacheContext.setupPluginLoadInProgressIds.has(id)) { return undefined; } - const entry = getLazyGeneratedBundledChannelEntryForRoot(id, packageRoot, cacheContext, { + const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext, { includeSetup: true, })?.setupEntry; if (!entry) { @@ -380,13 +398,13 @@ function getBundledChannelSetupPluginForRoot( function getBundledChannelSetupSecretsForRoot( id: ChannelId, - packageRoot: string, + rootScope: BundledChannelRootScope, cacheContext: BundledChannelCacheContext, ): ChannelPlugin["secrets"] | undefined { if (cacheContext.lazySetupSecretsById.has(id)) { return cacheContext.lazySetupSecretsById.get(id) ?? undefined; } - const entry = getLazyGeneratedBundledChannelEntryForRoot(id, packageRoot, cacheContext, { + const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext, { includeSetup: true, })?.setupEntry; if (!entry) { @@ -394,23 +412,23 @@ function getBundledChannelSetupSecretsForRoot( } const secrets = entry.loadSetupSecrets?.() ?? - getBundledChannelSetupPluginForRoot(id, packageRoot, cacheContext)?.secrets; + getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext)?.secrets; cacheContext.lazySetupSecretsById.set(id, secrets ?? null); return secrets; } export function listBundledChannelPlugins(): readonly ChannelPlugin[] { - const { packageRoot, cacheContext } = resolveActiveBundledChannelCacheScope(); - return listBundledChannelPluginIdsForRoot(packageRoot).flatMap((id) => { - const plugin = getBundledChannelPluginForRoot(id, packageRoot, cacheContext); + const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + return listBundledChannelPluginIdsForRoot(rootScope).flatMap((id) => { + const plugin = getBundledChannelPluginForRoot(id, rootScope, cacheContext); return plugin ? [plugin] : []; }); } export function listBundledChannelSetupPlugins(): readonly ChannelPlugin[] { - const { packageRoot, cacheContext } = resolveActiveBundledChannelCacheScope(); - return listBundledChannelPluginIdsForRoot(packageRoot).flatMap((id) => { - const plugin = getBundledChannelSetupPluginForRoot(id, packageRoot, cacheContext); + const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + return listBundledChannelPluginIdsForRoot(rootScope).flatMap((id) => { + const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext); return plugin ? [plugin] : []; }); } @@ -418,37 +436,37 @@ export function listBundledChannelSetupPlugins(): readonly ChannelPlugin[] { export function listBundledChannelSetupPluginsByFeature( feature: keyof NonNullable, ): readonly ChannelPlugin[] { - const { packageRoot, cacheContext } = resolveActiveBundledChannelCacheScope(); - return listBundledChannelPluginIdsForRoot(packageRoot).flatMap((id) => { - const setupEntry = getLazyGeneratedBundledChannelEntryForRoot(id, packageRoot, cacheContext, { + const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + return listBundledChannelPluginIdsForRoot(rootScope).flatMap((id) => { + const setupEntry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext, { includeSetup: true, })?.setupEntry; if (!hasSetupEntryFeature(setupEntry, feature)) { return []; } - const plugin = getBundledChannelSetupPluginForRoot(id, packageRoot, cacheContext); + const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext); return plugin ? [plugin] : []; }); } export function getBundledChannelPlugin(id: ChannelId): ChannelPlugin | undefined { - const { packageRoot, cacheContext } = resolveActiveBundledChannelCacheScope(); - return getBundledChannelPluginForRoot(id, packageRoot, cacheContext); + const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + return getBundledChannelPluginForRoot(id, rootScope, cacheContext); } export function getBundledChannelSecrets(id: ChannelId): ChannelPlugin["secrets"] | undefined { - const { packageRoot, cacheContext } = resolveActiveBundledChannelCacheScope(); - return getBundledChannelSecretsForRoot(id, packageRoot, cacheContext); + const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + return getBundledChannelSecretsForRoot(id, rootScope, cacheContext); } export function getBundledChannelSetupPlugin(id: ChannelId): ChannelPlugin | undefined { - const { packageRoot, cacheContext } = resolveActiveBundledChannelCacheScope(); - return getBundledChannelSetupPluginForRoot(id, packageRoot, cacheContext); + const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + return getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext); } export function getBundledChannelSetupSecrets(id: ChannelId): ChannelPlugin["secrets"] | undefined { - const { packageRoot, cacheContext } = resolveActiveBundledChannelCacheScope(); - return getBundledChannelSetupSecretsForRoot(id, packageRoot, cacheContext); + const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + return getBundledChannelSetupSecretsForRoot(id, rootScope, cacheContext); } export function requireBundledChannelPlugin(id: ChannelId): ChannelPlugin { @@ -460,8 +478,8 @@ export function requireBundledChannelPlugin(id: ChannelId): ChannelPlugin { } export function setBundledChannelRuntime(id: ChannelId, runtime: PluginRuntime): void { - const { packageRoot, cacheContext } = resolveActiveBundledChannelCacheScope(); - const setter = getLazyGeneratedBundledChannelEntryForRoot(id, packageRoot, cacheContext)?.entry + const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + const setter = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext)?.entry .setChannelRuntime; if (!setter) { throw new Error(`missing bundled channel runtime setter: ${id}`); diff --git a/src/plugins/bundled-channel-runtime.ts b/src/plugins/bundled-channel-runtime.ts index 5e751dbf20f..57f00e90909 100644 --- a/src/plugins/bundled-channel-runtime.ts +++ b/src/plugins/bundled-channel-runtime.ts @@ -9,6 +9,7 @@ export type BundledChannelPluginMetadata = BundledPluginMetadata; export function listBundledChannelPluginMetadata(params?: { rootDir?: string; + scanDir?: string; includeChannelConfigs?: boolean; includeSyntheticChannelConfigs?: boolean; }): readonly BundledChannelPluginMetadata[] { @@ -19,12 +20,14 @@ export function resolveBundledChannelGeneratedPath( rootDir: string, entry: BundledPluginMetadata["source"] | BundledPluginMetadata["setupSource"], pluginDirName?: string, + scanDir?: string, ): string | null { - return resolveBundledPluginGeneratedPath(rootDir, entry, pluginDirName); + return resolveBundledPluginGeneratedPath(rootDir, entry, pluginDirName, scanDir); } export function resolveBundledChannelWorkspacePath(params: { rootDir: string; + scanDir?: string; pluginId: string; }): string | null { return resolveBundledPluginWorkspaceSourcePath(params); diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index 9bbbb92693a..ee9d4e8fa31 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -274,6 +274,45 @@ describe("bundled plugin metadata", () => { ); }); + it("scans direct plugin-tree overrides and resolves generated paths from that scan dir", () => { + const tempRoot = createGeneratedPluginTempRoot("openclaw-bundled-plugin-direct-tree-"); + const pluginsDir = path.join(tempRoot, "bundled-plugins"); + const pluginRoot = path.join(pluginsDir, "alpha"); + + writeJson(path.join(pluginRoot, "package.json"), { + name: "@openclaw/alpha", + version: "0.0.1", + openclaw: { + extensions: ["./index.ts"], + }, + }); + writeJson(path.join(pluginRoot, "openclaw.plugin.json"), { + id: "alpha", + channels: ["alpha"], + configSchema: { type: "object" }, + }); + fs.writeFileSync(path.join(pluginRoot, "index.ts"), "export const source = true;\n", "utf8"); + + clearBundledPluginMetadataCache(); + expect( + listBundledPluginMetadata({ + rootDir: tempRoot, + scanDir: pluginsDir, + }).map((entry) => entry.manifest.id), + ).toEqual(["alpha"]); + expect( + resolveBundledPluginGeneratedPath( + tempRoot, + { + source: "./index.ts", + built: "index.js", + }, + "alpha", + pluginsDir, + ), + ).toBe(path.join(pluginRoot, "index.ts")); + }); + it("resolves bundled repo entry paths from dist before workspace source", () => { const tempRoot = createGeneratedPluginTempRoot("openclaw-bundled-plugin-repo-entry-"); const pluginRoot = path.join(tempRoot, "extensions", "alpha"); diff --git a/src/plugins/bundled-plugin-metadata.ts b/src/plugins/bundled-plugin-metadata.ts index 63283cd48b0..be3b06c567c 100644 --- a/src/plugins/bundled-plugin-metadata.ts +++ b/src/plugins/bundled-plugin-metadata.ts @@ -67,26 +67,44 @@ function readPackageManifest(pluginDir: string): PackageManifest | undefined { } } -function collectBundledPluginMetadataForPackageRoot( +function resolveBundledPluginMetadataScanDir( packageRoot: string, - includeChannelConfigs: boolean, - includeSyntheticChannelConfigs: boolean, -): readonly BundledPluginMetadata[] { - const scanDir = resolveBundledPluginScanDir({ + scanDir?: string, +): string | undefined { + if (scanDir) { + return path.resolve(scanDir); + } + return resolveBundledPluginScanDir({ packageRoot, runningFromBuiltArtifact: RUNNING_FROM_BUILT_ARTIFACT, }); - if (!scanDir || !fs.existsSync(scanDir)) { +} + +function resolveBundledPluginLookupParams(params: { rootDir: string; scanDir?: string }): { + rootDir: string; + scanDir?: string; +} { + return params.scanDir ? params : { rootDir: params.rootDir }; +} + +function collectBundledPluginMetadata( + packageRoot: string, + includeChannelConfigs: boolean, + includeSyntheticChannelConfigs: boolean, + scanDir?: string, +): readonly BundledPluginMetadata[] { + const resolvedScanDir = resolveBundledPluginMetadataScanDir(packageRoot, scanDir); + if (!resolvedScanDir || !fs.existsSync(resolvedScanDir)) { return []; } const entries: BundledPluginMetadata[] = []; for (const dirName of fs - .readdirSync(scanDir, { withFileTypes: true }) + .readdirSync(resolvedScanDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .toSorted((left, right) => left.localeCompare(right))) { - const pluginDir = path.join(scanDir, dirName); + const pluginDir = path.join(resolvedScanDir, dirName); const manifestResult = loadPluginManifest(pluginDir, false); if (!manifestResult.ok) { continue; @@ -165,15 +183,18 @@ function collectBundledPluginMetadataForPackageRoot( export function listBundledPluginMetadata(params?: { rootDir?: string; + scanDir?: string; includeChannelConfigs?: boolean; includeSyntheticChannelConfigs?: boolean; }): readonly BundledPluginMetadata[] { const rootDir = path.resolve(params?.rootDir ?? OPENCLAW_PACKAGE_ROOT); + const scanDir = params?.scanDir ? path.resolve(params.scanDir) : undefined; const includeChannelConfigs = params?.includeChannelConfigs ?? !RUNNING_FROM_BUILT_ARTIFACT; const includeSyntheticChannelConfigs = params?.includeSyntheticChannelConfigs ?? includeChannelConfigs; const cacheKey = JSON.stringify({ rootDir, + scanDir, includeChannelConfigs, includeSyntheticChannelConfigs, }); @@ -182,10 +203,11 @@ export function listBundledPluginMetadata(params?: { return cached; } const entries = Object.freeze( - collectBundledPluginMetadataForPackageRoot( + collectBundledPluginMetadata( rootDir, includeChannelConfigs, includeSyntheticChannelConfigs, + scanDir, ), ); bundledPluginMetadataCache.set(cacheKey, entries); @@ -194,26 +216,50 @@ export function listBundledPluginMetadata(params?: { export function findBundledPluginMetadataById( pluginId: string, - params?: { rootDir?: string }, + params?: { rootDir?: string; scanDir?: string }, ): BundledPluginMetadata | undefined { return listBundledPluginMetadata(params).find((entry) => entry.manifest.id === pluginId); } export function resolveBundledPluginWorkspaceSourcePath(params: { rootDir: string; + scanDir?: string; pluginId: string; }): string | null { - const metadata = findBundledPluginMetadataById(params.pluginId, { rootDir: params.rootDir }); + const metadata = findBundledPluginMetadataById( + params.pluginId, + resolveBundledPluginLookupParams({ + rootDir: params.rootDir, + scanDir: params.scanDir, + }), + ); if (!metadata) { return null; } + if (params.scanDir) { + return path.resolve(params.scanDir, metadata.dirName); + } return path.resolve(params.rootDir, "extensions", metadata.dirName); } +function listBundledPluginEntryBaseDirs(params: { + rootDir: string; + pluginDirName?: string; + scanDir?: string; +}): string[] { + const baseDirs = [ + path.resolve(params.rootDir, "dist", "extensions", params.pluginDirName ?? ""), + path.resolve(params.rootDir, "extensions", params.pluginDirName ?? ""), + ...(params.scanDir ? [path.resolve(params.scanDir, params.pluginDirName ?? "")] : []), + ]; + return baseDirs.filter((entry, index, all) => all.indexOf(entry) === index); +} + export function resolveBundledPluginGeneratedPath( rootDir: string, entry: BundledPluginPathPair | undefined, pluginDirName?: string, + scanDir?: string, ): string | null { if (!entry) { return null; @@ -221,10 +267,11 @@ export function resolveBundledPluginGeneratedPath( const entryOrder = [entry.built, entry.source].filter( (candidate): candidate is string => typeof candidate === "string" && candidate.length > 0, ); - const baseDirs = [ - path.resolve(rootDir, "dist", "extensions", pluginDirName ?? ""), - path.resolve(rootDir, "extensions", pluginDirName ?? ""), - ]; + const baseDirs = listBundledPluginEntryBaseDirs({ + rootDir, + pluginDirName, + ...(scanDir ? { scanDir } : {}), + }); for (const baseDir of baseDirs) { for (const entryPath of entryOrder) { const candidate = path.resolve(baseDir, normalizeRelativePluginEntryPath(entryPath)); @@ -244,8 +291,15 @@ export function resolveBundledPluginRepoEntryPath(params: { rootDir: string; pluginId: string; preferBuilt?: boolean; + scanDir?: string; }): string | null { - const metadata = findBundledPluginMetadataById(params.pluginId, { rootDir: params.rootDir }); + const metadata = findBundledPluginMetadataById( + params.pluginId, + resolveBundledPluginLookupParams({ + rootDir: params.rootDir, + scanDir: params.scanDir, + }), + ); if (!metadata) { return null; } @@ -253,10 +307,11 @@ export function resolveBundledPluginRepoEntryPath(params: { const entryOrder = params.preferBuilt ? [metadata.source.built, metadata.source.source] : [metadata.source.source, metadata.source.built]; - const baseDirs = [ - path.resolve(params.rootDir, "dist", "extensions", metadata.dirName), - path.resolve(params.rootDir, "extensions", metadata.dirName), - ]; + const baseDirs = listBundledPluginEntryBaseDirs({ + rootDir: params.rootDir, + pluginDirName: metadata.dirName, + ...(params.scanDir ? { scanDir: params.scanDir } : {}), + }); for (const baseDir of baseDirs) { for (const entryPath of entryOrder) {