From 5da998fe8a44fd99d3da0f631d2ccae44e55e407 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 10:53:16 -0700 Subject: [PATCH] refactor(channels): consolidate lightweight plugin discovery (#117541) --- src/channels/plugins/bundled.ts | 745 ++++++++++--------------- src/channels/plugins/catalog.test.ts | 27 +- src/channels/plugins/catalog.ts | 262 ++++----- src/channels/plugins/read-only.test.ts | 134 +---- src/channels/plugins/read-only.ts | 235 ++------ 5 files changed, 447 insertions(+), 956 deletions(-) diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index 5e2ccac8ba71..322207d8eafe 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -77,30 +77,35 @@ type BundledChannelPackageSetupFeature = | "legacyStateMigrations" | "legacySessionSurfaces"; -type GeneratedBundledChannelEntry = { - id: string; +type BundledChannelArtifactValues = { entry: BundledChannelEntryRuntimeContract; + setupEntry: BundledChannelSetupEntryRuntimeContract; + plugin: ChannelPlugin; + setupPlugin: ChannelPlugin; + secrets: NonNullable; + setupSecrets: NonNullable; + accountInspector: NonNullable; }; +type BundledChannelArtifactKind = keyof BundledChannelArtifactValues; +type BundledChannelEntryKind = "entry" | "setupEntry"; +type BundledChannelArtifacts = Partial<{ + [Kind in BundledChannelArtifactKind]: BundledChannelArtifactValues[Kind] | null; +}>; + type BundledChannelLoadContext = { - pluginLoadInProgressIds: Set; - setupPluginLoadInProgressIds: Set; - entryLoadInProgressIds: Set; - setupEntryLoadInProgressIds: Set; - lazyEntriesById: Map; - lazySetupEntriesById: Map; - lazyPluginsById: Map; - lazySetupPluginsById: Map; - lazySecretsById: Map; - lazySetupSecretsById: Map; - lazyAccountInspectorsById: Map< - ChannelId, - NonNullable | null - >; + artifactLoadsInProgress: Set; + artifactsById: Map; metadataById: Map; metadataLoaded: boolean; }; +type BundledChannelArtifactLoadParams = { + id: ChannelId; + rootScope: BundledChannelRootScope; + loadContext: BundledChannelLoadContext; +}; + const log = createSubsystemLogger("channels"); const MAX_BUNDLED_CHANNEL_LOAD_CONTEXTS = 32; const MAX_BUNDLED_CHANNEL_BOUNDARY_ROOTS = 256; @@ -108,6 +113,23 @@ const bundledChannelLoadContextsByRoot = new Map(); const sourceBundledEntryLoaderCache: PluginModuleLoaderCache = new Map(); +function rememberBoundedBundledChannelValue( + cache: Map, + key: TKey, + value: TValue, + maxSize: number, +): TValue { + cache.delete(key); + cache.set(key, value); + if (cache.size > maxSize) { + const oldestKey = cache.keys().next().value; + if (oldestKey !== undefined) { + cache.delete(oldestKey); + } + } + return value; +} + function isSourceModulePath(modulePath: string): boolean { return /\.(?:c|m)?tsx?$/iu.test(modulePath); } @@ -141,51 +163,28 @@ function isPackageLocalBundledDistModulePath(params: { return distRoots.some((root) => isPathInsideCanonicalRoot(root, params.modulePath)); } -function resolveChannelPluginModuleEntry( +function resolveBundledChannelModuleEntry( moduleExport: unknown, -): BundledChannelEntryRuntimeContract | null { + kind: TKind, +): BundledChannelArtifactValues[TKind] | null { const resolved = unwrapDefaultModuleExport(moduleExport); if (!resolved || typeof resolved !== "object") { return null; } - const record = resolved as Partial; - if (record.kind !== "bundled-channel-entry") { + const record = resolved as Record; + const setup = kind === "setupEntry"; + if (record.kind !== (setup ? "bundled-channel-setup-entry" : "bundled-channel-entry")) { return null; } + const stringFields = setup ? [] : ["id", "name", "description"]; + const functionFields = setup ? ["loadSetupPlugin"] : ["register", "loadChannelPlugin"]; if ( - typeof record.id !== "string" || - typeof record.name !== "string" || - typeof record.description !== "string" || - typeof record.register !== "function" || - typeof record.loadChannelPlugin !== "function" + stringFields.some((field) => typeof record[field] !== "string") || + functionFields.some((field) => typeof record[field] !== "function") ) { return null; } - return record as BundledChannelEntryRuntimeContract; -} - -function resolveChannelSetupModuleEntry( - moduleExport: unknown, -): BundledChannelSetupEntryRuntimeContract | null { - const resolved = unwrapDefaultModuleExport(moduleExport); - if (!resolved || typeof resolved !== "object") { - return null; - } - const record = resolved as Partial; - if (record.kind !== "bundled-channel-setup-entry") { - return null; - } - if (typeof record.loadSetupPlugin !== "function") { - return null; - } - return record as BundledChannelSetupEntryRuntimeContract; -} - -function hasSetupEntryFeature( - entry: BundledChannelSetupEntryRuntimeContract | null | undefined, - feature: keyof NonNullable, -): boolean { - return entry?.features?.[feature] === true; + return record as BundledChannelArtifactValues[TKind]; } function resolveBundledChannelBoundaryRoot(params: { @@ -202,59 +201,33 @@ function resolveBundledChannelBoundaryRoot(params: { ].join("\0"); const cached = bundledChannelBoundaryRoots.get(cacheKey); if (cached) { - bundledChannelBoundaryRoots.delete(cacheKey); - bundledChannelBoundaryRoots.set(cacheKey, cached); - return cached; + return rememberBoundedBundledChannelValue( + bundledChannelBoundaryRoots, + cacheKey, + cached, + MAX_BUNDLED_CHANNEL_BOUNDARY_ROOTS, + ); } const canonicalModulePath = resolveCanonicalPathOrAbsolute(params.modulePath); - const resolveMatchingRoot = (root: string): string | null => { - const canonicalRoot = resolveCanonicalPathOrAbsolute(root); - return isPathInside(canonicalRoot, canonicalModulePath) ? canonicalRoot : null; - }; - const overrideRoot = params.pluginsDir - ? path.resolve(params.pluginsDir, params.metadata.dirName) - : null; - let boundaryRoot: string; - const overrideBoundaryRoot = overrideRoot ? resolveMatchingRoot(overrideRoot) : null; - if (overrideBoundaryRoot) { - boundaryRoot = overrideBoundaryRoot; - } else { - const distRoot = path.resolve( - params.packageRoot, - "dist", - "extensions", - params.metadata.dirName, - ); - const distBoundaryRoot = resolveMatchingRoot(distRoot); - if (distBoundaryRoot) { - boundaryRoot = distBoundaryRoot; - } else { - const distRuntimeRoot = path.resolve( - params.packageRoot, - "dist-runtime", - "extensions", - params.metadata.dirName, - ); - boundaryRoot = - resolveMatchingRoot(distRuntimeRoot) ?? - resolveCanonicalPathOrAbsolute( - path.resolve(params.packageRoot, "extensions", params.metadata.dirName), - ); - } - } - bundledChannelBoundaryRoots.set(cacheKey, boundaryRoot); - while (bundledChannelBoundaryRoots.size > MAX_BUNDLED_CHANNEL_BOUNDARY_ROOTS) { - const oldestKey = bundledChannelBoundaryRoots.keys().next().value; - if (oldestKey === undefined) { - break; - } - bundledChannelBoundaryRoots.delete(oldestKey); - } - return boundaryRoot; -} - -function resolveBundledChannelScanDir(rootScope: BundledChannelRootScope): string | undefined { - return rootScope.pluginsDir; + const sourceRoot = path.resolve(params.packageRoot, "extensions", params.metadata.dirName); + const candidates = [ + ...(params.pluginsDir ? [path.resolve(params.pluginsDir, params.metadata.dirName)] : []), + ...["dist", "dist-runtime"].map((layout) => + path.resolve(params.packageRoot, layout, "extensions", params.metadata.dirName), + ), + sourceRoot, + ]; + const boundaryRoot = + candidates + .map(resolveCanonicalPathOrAbsolute) + .find((root) => isPathInside(root, canonicalModulePath)) ?? + resolveCanonicalPathOrAbsolute(sourceRoot); + return rememberBoundedBundledChannelValue( + bundledChannelBoundaryRoots, + cacheKey, + boundaryRoot, + MAX_BUNDLED_CHANNEL_BOUNDARY_ROOTS, + ); } function resolveGeneratedBundledChannelModulePath(params: { @@ -269,7 +242,7 @@ function resolveGeneratedBundledChannelModulePath(params: { params.rootScope.packageRoot, params.entry, params.metadata.dirName, - resolveBundledChannelScanDir(params.rootScope), + params.rootScope.pluginsDir, ); if (generatedPath) { return generatedPath; @@ -318,7 +291,7 @@ function loadGeneratedBundledChannelModule(params: { if (!modulePath) { throw new Error(`missing generated module for bundled channel ${params.metadata.manifest.id}`); } - const scanDir = resolveBundledChannelScanDir(params.rootScope); + const scanDir = params.rootScope.pluginsDir; const boundaryRoot = resolveBundledChannelBoundaryRoot({ packageRoot: params.rootScope.packageRoot, ...(scanDir ? { pluginsDir: scanDir } : {}), @@ -382,79 +355,47 @@ function describeBundledChannelLoadError(error: unknown, channelId: string): str return detail; } -function loadGeneratedBundledChannelEntry(params: { - rootScope: BundledChannelRootScope; - metadata: BundledChannelPluginMetadata; -}): GeneratedBundledChannelEntry | null { +function loadGeneratedBundledChannelEntry( + kind: TKind, + rootScope: BundledChannelRootScope, + metadata: BundledChannelPluginMetadata, +): BundledChannelArtifactValues[TKind] | undefined { + const setup = kind === "setupEntry"; + const source = setup ? metadata.setupSource : metadata.source; + if (setup && !source) { + return undefined; + } try { - const entry = resolveChannelPluginModuleEntry( + const entry = resolveBundledChannelModuleEntry( loadGeneratedBundledChannelModule({ - rootScope: params.rootScope, - metadata: params.metadata, - entry: params.metadata.source, + rootScope, + metadata, + entry: source, }), + kind, ); if (!entry) { + const description = setup ? "setup entry" : "entry"; + const contract = setup ? "bundled-channel-setup-entry" : "bundled-channel-entry"; log.warn( - `[channels] bundled channel entry ${params.metadata.manifest.id} missing bundled-channel-entry contract; skipping`, + `[channels] bundled channel ${description} ${metadata.manifest.id} missing ${contract} contract; skipping`, ); - return null; } - return { - id: params.metadata.manifest.id, - entry, - }; + return entry ?? undefined; } catch (error) { - const detail = describeBundledChannelLoadError(error, params.metadata.manifest.id); - log.warn(`[channels] failed to load bundled channel ${params.metadata.manifest.id}: ${detail}`); - return null; - } -} - -function loadGeneratedBundledChannelSetupEntry(params: { - rootScope: BundledChannelRootScope; - metadata: BundledChannelPluginMetadata; -}): BundledChannelSetupEntryRuntimeContract | null { - if (!params.metadata.setupSource) { - return null; - } - try { - const setupEntry = resolveChannelSetupModuleEntry( - loadGeneratedBundledChannelModule({ - rootScope: params.rootScope, - metadata: params.metadata, - entry: params.metadata.setupSource, - }), - ); - if (!setupEntry) { - log.warn( - `[channels] bundled channel setup entry ${params.metadata.manifest.id} missing bundled-channel-setup-entry contract; skipping`, - ); - return null; - } - return setupEntry; - } catch (error) { - const detail = describeBundledChannelLoadError(error, params.metadata.manifest.id); + const detail = describeBundledChannelLoadError(error, metadata.manifest.id); + const description = setup ? " setup entry" : ""; log.warn( - `[channels] failed to load bundled channel setup entry ${params.metadata.manifest.id}: ${detail}`, + `[channels] failed to load bundled channel${description} ${metadata.manifest.id}: ${detail}`, ); - return null; + return undefined; } } function createBundledChannelLoadContext(): BundledChannelLoadContext { return { - pluginLoadInProgressIds: new Set(), - setupPluginLoadInProgressIds: new Set(), - entryLoadInProgressIds: new Set(), - setupEntryLoadInProgressIds: new Set(), - lazyEntriesById: new Map(), - lazySetupEntriesById: new Map(), - lazyPluginsById: new Map(), - lazySetupPluginsById: new Map(), - lazySecretsById: new Map(), - lazySetupSecretsById: new Map(), - lazyAccountInspectorsById: new Map(), + artifactLoadsInProgress: new Set(), + artifactsById: new Map(), metadataById: new Map(), metadataLoaded: false, }; @@ -465,24 +406,12 @@ function resolveActiveBundledChannelLoadScope(env: NodeJS.ProcessEnv = process.e loadContext: BundledChannelLoadContext; } { const rootScope = resolveBundledChannelRootScope(env); - const cachedContext = bundledChannelLoadContextsByRoot.get(rootScope.cacheKey); - if (cachedContext) { - bundledChannelLoadContextsByRoot.delete(rootScope.cacheKey); - bundledChannelLoadContextsByRoot.set(rootScope.cacheKey, cachedContext); - return { - rootScope, - loadContext: cachedContext, - }; - } - const loadContext = createBundledChannelLoadContext(); - bundledChannelLoadContextsByRoot.set(rootScope.cacheKey, loadContext); - while (bundledChannelLoadContextsByRoot.size > MAX_BUNDLED_CHANNEL_LOAD_CONTEXTS) { - const oldestKey = bundledChannelLoadContextsByRoot.keys().next().value; - if (oldestKey === undefined) { - break; - } - bundledChannelLoadContextsByRoot.delete(oldestKey); - } + const loadContext = rememberBoundedBundledChannelValue( + bundledChannelLoadContextsByRoot, + rootScope.cacheKey, + bundledChannelLoadContextsByRoot.get(rootScope.cacheKey) ?? createBundledChannelLoadContext(), + MAX_BUNDLED_CHANNEL_LOAD_CONTEXTS, + ); return { rootScope, loadContext, @@ -492,7 +421,7 @@ function resolveActiveBundledChannelLoadScope(env: NodeJS.ProcessEnv = process.e function listBundledChannelMetadata( rootScope = resolveBundledChannelRootScope(), ): readonly BundledChannelPluginMetadata[] { - const scanDir = resolveBundledChannelScanDir(rootScope); + const scanDir = rootScope.pluginsDir; return listBundledChannelPluginMetadata({ rootDir: rootScope.packageRoot, ...(scanDir ? { scanDir } : {}), @@ -554,28 +483,15 @@ function listBundledChannelPluginIdsForSetupFeature( feature: keyof NonNullable, options: { config?: OpenClawConfig } = {}, ): readonly ChannelId[] { - const hinted = listBundledChannelMetadata(rootScope) - .filter( - (metadata) => - metadata.packageManifest?.setupFeatures?.[feature] === true && - shouldIncludeBundledChannelSetupFeatureForConfig({ - metadata, - config: options.config, - }), - ) + const eligible = listBundledChannelMetadata(rootScope).filter((metadata) => + shouldIncludeBundledChannelSetupFeatureForConfig({ metadata, config: options.config }), + ); + const hinted = eligible.filter( + (metadata) => metadata.packageManifest?.setupFeatures?.[feature] === true, + ); + return (hinted.length > 0 ? hinted : eligible) .map((metadata) => metadata.manifest.id) .toSorted((left, right) => left.localeCompare(right)); - return hinted.length > 0 - ? hinted - : listBundledChannelMetadata(rootScope) - .filter((metadata) => - shouldIncludeBundledChannelSetupFeatureForConfig({ - metadata, - config: options.config, - }), - ) - .map((metadata) => metadata.manifest.id) - .toSorted((left, right) => left.localeCompare(right)); } export function listBundledChannelPluginIds(): readonly ChannelId[] { @@ -621,241 +537,146 @@ function resolveBundledChannelMetadata( return undefined; } -function getLazyGeneratedBundledChannelEntryForRoot( +function rememberBundledChannelArtifact( + loadContext: BundledChannelLoadContext, + kind: TKind, + id: ChannelId, + artifact: BundledChannelArtifactValues[TKind] | undefined, +): void { + const artifacts = loadContext.artifactsById.get(id) ?? {}; + artifacts[kind] = artifact ?? null; + loadContext.artifactsById.set(id, artifacts); +} + +function getBundledChannelArtifactForRoot( + kind: TKind, id: ChannelId, rootScope: BundledChannelRootScope, loadContext: BundledChannelLoadContext, -): GeneratedBundledChannelEntry | null { - const previous = loadContext.lazyEntriesById.get(id); - if (previous) { - return previous; +): BundledChannelArtifactValues[TKind] | undefined { + const artifacts = loadContext.artifactsById.get(id); + if (artifacts && Object.hasOwn(artifacts, kind)) { + return artifacts[kind] ?? undefined; } - if (previous === null) { - return null; + // Keep failure and recursion state separate by artifact kind: broken secrets + // must never poison the runtime plugin, setup plugin, or entry contracts. + const loadKey = `${kind}\0${id}`; + if (loadContext.artifactLoadsInProgress.has(loadKey)) { + return undefined; } - const metadata = resolveBundledChannelMetadata(id, rootScope, loadContext); - if (!metadata) { - loadContext.lazyEntriesById.set(id, null); - return null; - } - if (loadContext.entryLoadInProgressIds.has(id)) { - return null; - } - loadContext.entryLoadInProgressIds.add(id); + loadContext.artifactLoadsInProgress.add(loadKey); try { - const entry = loadGeneratedBundledChannelEntry({ - rootScope, - metadata, - }); - loadContext.lazyEntriesById.set(id, entry); - if (entry?.entry.id && entry.entry.id !== id) { - loadContext.lazyEntriesById.set(entry.entry.id, entry); + const artifact = bundledChannelArtifactLoaders[kind]({ id, rootScope, loadContext }); + rememberBundledChannelArtifact(loadContext, kind, id, artifact); + return artifact; + } catch (error) { + if (kind === "entry" || kind === "setupEntry") { + throw error; } - return entry; - } finally { - loadContext.entryLoadInProgressIds.delete(id); - } -} - -function rememberBundledChannelSetupEntry( - metadata: BundledChannelPluginMetadata, - loadContext: BundledChannelLoadContext, - entry: BundledChannelSetupEntryRuntimeContract | null, - requestedId?: ChannelId, -) { - const ids = new Set([ - metadata.manifest.id, - ...(metadata.manifest.channels ?? []), - ...(requestedId ? [requestedId] : []), - ]); - for (const id of ids) { - loadContext.lazySetupEntriesById.set(id, entry); - } -} - -function getLazyGeneratedBundledChannelSetupEntryForRoot( - id: ChannelId, - rootScope: BundledChannelRootScope, - loadContext: BundledChannelLoadContext, -): BundledChannelSetupEntryRuntimeContract | null { - if (loadContext.lazySetupEntriesById.has(id)) { - return loadContext.lazySetupEntriesById.get(id) ?? null; - } - const metadata = resolveBundledChannelMetadata(id, rootScope, loadContext); - if (!metadata) { - loadContext.lazySetupEntriesById.set(id, null); - return null; - } - if (loadContext.setupEntryLoadInProgressIds.has(id)) { - return null; - } - loadContext.setupEntryLoadInProgressIds.add(id); - try { - const setupEntry = loadGeneratedBundledChannelSetupEntry({ - rootScope, - metadata, - }); - rememberBundledChannelSetupEntry(metadata, loadContext, setupEntry, id); - return setupEntry; - } finally { - loadContext.setupEntryLoadInProgressIds.delete(id); - } -} - -function getBundledChannelPluginForRoot( - id: ChannelId, - rootScope: BundledChannelRootScope, - loadContext: BundledChannelLoadContext, -): ChannelPlugin | undefined { - if (loadContext.lazyPluginsById.has(id)) { - return loadContext.lazyPluginsById.get(id) ?? undefined; - } - if (loadContext.pluginLoadInProgressIds.has(id)) { + const descriptions: Record = { + entry: "", + setupEntry: " setup entry", + plugin: "", + setupPlugin: " setup", + secrets: " secrets", + setupSecrets: " setup secrets", + accountInspector: " account inspector", + }; + const detail = describeBundledChannelLoadError(error, id); + log.warn(`[channels] failed to load bundled channel${descriptions[kind]} ${id}: ${detail}`); + rememberBundledChannelArtifact(loadContext, kind, id, undefined); return undefined; + } finally { + loadContext.artifactLoadsInProgress.delete(loadKey); } - const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, loadContext)?.entry; - if (!entry) { - return undefined; - } - loadContext.pluginLoadInProgressIds.add(id); - try { +} + +const bundledChannelArtifactLoaders: { + [Kind in BundledChannelArtifactKind]: ( + params: BundledChannelArtifactLoadParams, + ) => BundledChannelArtifactValues[Kind] | undefined; +} = { + entry({ id, rootScope, loadContext }) { const metadata = resolveBundledChannelMetadata(id, rootScope, loadContext); - const plugin = entry.loadChannelPlugin() as ChannelPlugin | undefined; - if (!plugin) { - loadContext.lazyPluginsById.set(id, null); + if (!metadata) { return undefined; } - const normalizedPlugin = { - ...plugin, - meta: normalizeChannelMeta({ - id: plugin.id, - meta: plugin.meta, - existing: metadata?.packageManifest?.channel, - }), - }; - loadContext.lazyPluginsById.set(id, normalizedPlugin); - return normalizedPlugin; - } catch (error) { - const detail = describeBundledChannelLoadError(error, id); - log.warn(`[channels] failed to load bundled channel ${id}: ${detail}`); - loadContext.lazyPluginsById.set(id, null); - return undefined; - } finally { - loadContext.pluginLoadInProgressIds.delete(id); - } -} - -function getBundledChannelSecretsForRoot( - id: ChannelId, - rootScope: BundledChannelRootScope, - loadContext: BundledChannelLoadContext, -): ChannelPlugin["secrets"] | undefined { - if (loadContext.lazySecretsById.has(id)) { - return loadContext.lazySecretsById.get(id) ?? undefined; - } - const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, loadContext)?.entry; - if (!entry) { - return undefined; - } - try { - const secrets = - entry.loadChannelSecrets?.() ?? - getBundledChannelPluginForRoot(id, rootScope, loadContext)?.secrets; - loadContext.lazySecretsById.set(id, secrets ?? null); - return secrets; - } catch (error) { - const detail = describeBundledChannelLoadError(error, id); - log.warn(`[channels] failed to load bundled channel secrets ${id}: ${detail}`); - loadContext.lazySecretsById.set(id, null); - return undefined; - } -} - -function getBundledChannelAccountInspectorForRoot( - id: ChannelId, - rootScope: BundledChannelRootScope, - loadContext: BundledChannelLoadContext, -): NonNullable | undefined { - if (loadContext.lazyAccountInspectorsById.has(id)) { - return loadContext.lazyAccountInspectorsById.get(id) ?? undefined; - } - const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, loadContext)?.entry; - if (!entry?.loadChannelAccountInspector) { - loadContext.lazyAccountInspectorsById.set(id, null); - return undefined; - } - try { - const inspector = entry.loadChannelAccountInspector(); - loadContext.lazyAccountInspectorsById.set(id, inspector); - return inspector; - } catch (error) { - const detail = describeBundledChannelLoadError(error, id); - log.warn(`[channels] failed to load bundled channel account inspector ${id}: ${detail}`); - loadContext.lazyAccountInspectorsById.set(id, null); - return undefined; - } -} - -function getBundledChannelSetupPluginForRoot( - id: ChannelId, - rootScope: BundledChannelRootScope, - loadContext: BundledChannelLoadContext, -): ChannelPlugin | undefined { - if (loadContext.lazySetupPluginsById.has(id)) { - return loadContext.lazySetupPluginsById.get(id) ?? undefined; - } - if (loadContext.setupPluginLoadInProgressIds.has(id)) { - return undefined; - } - const entry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, loadContext); - if (!entry) { - return undefined; - } - loadContext.setupPluginLoadInProgressIds.add(id); - try { - const plugin = entry.loadSetupPlugin(); - loadContext.lazySetupPluginsById.set(id, plugin); - return plugin; - } catch (error) { - const detail = describeBundledChannelLoadError(error, id); - log.warn(`[channels] failed to load bundled channel setup ${id}: ${detail}`); - loadContext.lazySetupPluginsById.set(id, null); - return undefined; - } finally { - loadContext.setupPluginLoadInProgressIds.delete(id); - } -} - -function getBundledChannelSetupSecretsForRoot( - id: ChannelId, - rootScope: BundledChannelRootScope, - loadContext: BundledChannelLoadContext, -): ChannelPlugin["secrets"] | undefined { - if (loadContext.lazySetupSecretsById.has(id)) { - return loadContext.lazySetupSecretsById.get(id) ?? undefined; - } - const entry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, loadContext); - if (!entry) { - return undefined; - } - try { - const secrets = - entry.loadSetupSecrets?.() ?? - getBundledChannelSetupPluginForRoot(id, rootScope, loadContext)?.secrets; - loadContext.lazySetupSecretsById.set(id, secrets ?? null); - return secrets; - } catch (error) { - const detail = describeBundledChannelLoadError(error, id); - log.warn(`[channels] failed to load bundled channel setup secrets ${id}: ${detail}`); - loadContext.lazySetupSecretsById.set(id, null); - return undefined; - } -} + const entry = loadGeneratedBundledChannelEntry("entry", rootScope, metadata); + if (entry && entry.id !== id) { + rememberBundledChannelArtifact(loadContext, "entry", entry.id, entry); + } + return entry; + }, + setupEntry({ id, rootScope, loadContext }) { + const metadata = resolveBundledChannelMetadata(id, rootScope, loadContext); + if (!metadata) { + return undefined; + } + const entry = loadGeneratedBundledChannelEntry("setupEntry", rootScope, metadata); + const aliases = new Set([ + metadata.manifest.id, + ...(metadata.manifest.channels ?? []), + id, + ]); + for (const alias of aliases) { + rememberBundledChannelArtifact(loadContext, "setupEntry", alias, entry); + } + return entry; + }, + plugin({ id, rootScope, loadContext }) { + const entry = getBundledChannelArtifactForRoot("entry", id, rootScope, loadContext); + if (!entry) { + return undefined; + } + const metadata = resolveBundledChannelMetadata(id, rootScope, loadContext); + const plugin = entry.loadChannelPlugin() as ChannelPlugin | undefined; + return plugin + ? { + ...plugin, + meta: normalizeChannelMeta({ + id: plugin.id, + meta: plugin.meta, + existing: metadata?.packageManifest?.channel, + }), + } + : undefined; + }, + setupPlugin({ id, rootScope, loadContext }) { + return getBundledChannelArtifactForRoot( + "setupEntry", + id, + rootScope, + loadContext, + )?.loadSetupPlugin(); + }, + secrets({ id, rootScope, loadContext }) { + const entry = getBundledChannelArtifactForRoot("entry", id, rootScope, loadContext); + return entry + ? (entry.loadChannelSecrets?.() ?? + getBundledChannelArtifactForRoot("plugin", id, rootScope, loadContext)?.secrets) + : undefined; + }, + setupSecrets({ id, rootScope, loadContext }) { + const entry = getBundledChannelArtifactForRoot("setupEntry", id, rootScope, loadContext); + return entry + ? (entry.loadSetupSecrets?.() ?? + getBundledChannelArtifactForRoot("setupPlugin", id, rootScope, loadContext)?.secrets) + : undefined; + }, + accountInspector({ id, rootScope, loadContext }) { + return getBundledChannelArtifactForRoot( + "entry", + id, + rootScope, + loadContext, + )?.loadChannelAccountInspector?.(); + }, +}; export function listBundledChannelPlugins(): readonly ChannelPlugin[] { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); return listBundledChannelPluginIdsForRoot(rootScope).flatMap((id) => { - const plugin = getBundledChannelPluginForRoot(id, rootScope, loadContext); + const plugin = getBundledChannelArtifactForRoot("plugin", id, rootScope, loadContext); return plugin ? [plugin] : []; }); } @@ -863,72 +684,70 @@ export function listBundledChannelPlugins(): readonly ChannelPlugin[] { export function listBundledChannelSetupPlugins(): readonly ChannelPlugin[] { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); return listBundledChannelPluginIdsForRoot(rootScope).flatMap((id) => { - const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, loadContext); + const plugin = getBundledChannelArtifactForRoot("setupPlugin", id, rootScope, loadContext); return plugin ? [plugin] : []; }); } -export function listBundledChannelLegacySessionSurfaces( - options: { - config?: OpenClawConfig; - } = {}, -): readonly BundledChannelLegacySessionSurface[] { +function listBundledChannelLegacyArtifacts( + feature: keyof NonNullable, + options: { config?: OpenClawConfig }, + loadFromEntry: (entry: BundledChannelSetupEntryRuntimeContract) => TArtifact | undefined, + loadFromPlugin: (plugin: ChannelPlugin) => TArtifact | undefined, +): readonly TArtifact[] { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); - return listBundledChannelPluginIdsForSetupFeature(rootScope, "legacySessionSurfaces", { - config: options.config, - }).flatMap((id) => { - const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, loadContext); - const surface = setupEntry?.loadLegacySessionSurface?.(); - if (surface) { - return [surface]; + return listBundledChannelPluginIdsForSetupFeature(rootScope, feature, options).flatMap((id) => { + const entry = getBundledChannelArtifactForRoot("setupEntry", id, rootScope, loadContext); + const artifact = entry ? loadFromEntry(entry) : undefined; + if (artifact) { + return [artifact]; } - if (!hasSetupEntryFeature(setupEntry, "legacySessionSurfaces")) { + if (entry?.features?.[feature] !== true) { return []; } - const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, loadContext); - return plugin?.messaging ? [plugin.messaging] : []; + const plugin = getBundledChannelArtifactForRoot("setupPlugin", id, rootScope, loadContext); + const fallback = plugin ? loadFromPlugin(plugin) : undefined; + return fallback ? [fallback] : []; }); } +export function listBundledChannelLegacySessionSurfaces( + options: { config?: OpenClawConfig } = {}, +): readonly BundledChannelLegacySessionSurface[] { + return listBundledChannelLegacyArtifacts( + "legacySessionSurfaces", + options, + (entry) => entry.loadLegacySessionSurface?.(), + (plugin) => plugin.messaging, + ); +} + export function listBundledChannelLegacyStateMigrationDetectors( - options: { - config?: OpenClawConfig; - } = {}, + options: { config?: OpenClawConfig } = {}, ): readonly BundledChannelLegacyStateMigrationDetector[] { - const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); - return listBundledChannelPluginIdsForSetupFeature(rootScope, "legacyStateMigrations", { - config: options.config, - }).flatMap((id) => { - const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, loadContext); - const detector = setupEntry?.loadLegacyStateMigrationDetector?.(); - if (detector) { - return [detector]; - } - if (!hasSetupEntryFeature(setupEntry, "legacyStateMigrations")) { - return []; - } - const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, loadContext); - return plugin?.lifecycle?.detectLegacyStateMigrations - ? [plugin.lifecycle.detectLegacyStateMigrations] - : []; - }); + return listBundledChannelLegacyArtifacts( + "legacyStateMigrations", + options, + (entry) => entry.loadLegacyStateMigrationDetector?.(), + (plugin) => plugin.lifecycle?.detectLegacyStateMigrations, + ); } export function getBundledChannelAccountInspector( id: ChannelId, ): NonNullable | undefined { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); - return getBundledChannelAccountInspectorForRoot(id, rootScope, loadContext); + return getBundledChannelArtifactForRoot("accountInspector", id, rootScope, loadContext); } export function getBundledChannelPlugin(id: ChannelId): ChannelPlugin | undefined { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); - return getBundledChannelPluginForRoot(id, rootScope, loadContext); + return getBundledChannelArtifactForRoot("plugin", id, rootScope, loadContext); } export function getBundledChannelSecrets(id: ChannelId): ChannelPlugin["secrets"] | undefined { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); - return getBundledChannelSecretsForRoot(id, rootScope, loadContext); + return getBundledChannelArtifactForRoot("secrets", id, rootScope, loadContext); } export function getBundledChannelSetupPlugin( @@ -936,7 +755,7 @@ export function getBundledChannelSetupPlugin( env: NodeJS.ProcessEnv = process.env, ): ChannelPlugin | undefined { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(env); - return getBundledChannelSetupPluginForRoot(id, rootScope, loadContext); + return getBundledChannelArtifactForRoot("setupPlugin", id, rootScope, loadContext); } export function getBundledChannelSetupSecrets( @@ -944,13 +763,17 @@ export function getBundledChannelSetupSecrets( env: NodeJS.ProcessEnv = process.env, ): ChannelPlugin["secrets"] | undefined { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(env); - return getBundledChannelSetupSecretsForRoot(id, rootScope, loadContext); + return getBundledChannelArtifactForRoot("setupSecrets", id, rootScope, loadContext); } export function setBundledChannelRuntime(id: ChannelId, runtime: PluginRuntime): void { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); - const setter = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, loadContext)?.entry - .setChannelRuntime; + const setter = getBundledChannelArtifactForRoot( + "entry", + id, + rootScope, + loadContext, + )?.setChannelRuntime; if (!setter) { throw new Error(`missing bundled channel runtime setter: ${id}`); } diff --git a/src/channels/plugins/catalog.test.ts b/src/channels/plugins/catalog.test.ts index caf77cab1507..e8fa92947905 100644 --- a/src/channels/plugins/catalog.test.ts +++ b/src/channels/plugins/catalog.test.ts @@ -29,7 +29,12 @@ afterEach(() => { } }); -function writeChannelCatalog(catalogPath: string, id: string, label: string): void { +function writeChannelCatalog( + catalogPath: string, + id: string, + label: string, + defaultChoice?: string, +): void { fs.mkdirSync(path.dirname(catalogPath), { recursive: true }); fs.writeFileSync( catalogPath, @@ -39,7 +44,7 @@ function writeChannelCatalog(catalogPath: string, id: string, label: string): vo name: `@example/${id}`, openclaw: { channel: { id, label, selectionLabel: label, docsPath: `/channels/${id}`, blurb: id }, - install: { npmSpec: `@example/${id}` }, + install: { npmSpec: `@example/${id}`, ...(defaultChoice ? { defaultChoice } : {}) }, }, }, ], @@ -105,6 +110,24 @@ describe("channel plugin catalog", () => { })?.origin, ).toBe("bundled"); }); + + it.each(["__proto__", "constructor", "toString"])( + "rejects inherited install default choice %s from external catalog input", + (defaultChoice) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-channel-catalog-choice-")); + tempDirs.push(root); + const catalogPath = path.join(root, "catalog.json"); + writeChannelCatalog(catalogPath, "unsafe-choice", "Unsafe Choice", defaultChoice); + + const entry = getChannelPluginCatalogEntry("unsafe-choice", { + catalogPaths: [catalogPath], + workspaceDir: root, + env: {}, + }); + expect(entry?.install.defaultChoice).toBe("npm"); + }, + ); + it("reloads external catalog entries after the explicit plugin metadata lifecycle reset", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-channel-external-catalog-")); tempDirs.push(root); diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index b819fbfbca0b..48f65b213221 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -79,28 +79,23 @@ const ORIGIN_PRIORITY: Record = { bundled: 3, }; -function shouldExcludeCatalogOrigin(options: CatalogOptions, origin: PluginOrigin): boolean { - if (options.excludeWorkspace && origin === "workspace") { - return true; - } - return options.excludeOrigins?.includes(origin) ?? false; -} - -function shouldExcludeCatalogPlugin( +function shouldExcludeCatalogEntry( options: CatalogOptions, pluginId?: string, origin?: PluginOrigin, ): boolean { const normalizedPluginId = normalizeOptionalString(pluginId); - if (!normalizedPluginId) { - return false; - } return ( - options.excludePluginRefs?.some( - (entry) => - entry.pluginId === normalizedPluginId && - (entry.origin === undefined || entry.origin === origin), - ) ?? false + (options.excludeWorkspace === true && origin === "workspace") || + (origin !== undefined && (options.excludeOrigins?.includes(origin) ?? false)) || + Boolean( + normalizedPluginId && + options.excludePluginRefs?.some( + (entry) => + entry.pluginId === normalizedPluginId && + (entry.origin === undefined || entry.origin === origin), + ), + ) ); } @@ -115,47 +110,21 @@ type ExternalCatalogEntry = { const ENV_CATALOG_PATHS = ["OPENCLAW_PLUGIN_CATALOG_PATHS", "OPENCLAW_MPM_CATALOG_PATHS"]; const OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH = path.join("dist", "channel-catalog.json"); -const officialCatalogEntriesByPath = new Map(); -const externalCatalogEntriesByPath = new Map(); +const catalogEntriesByPath = new Map(); -registerPluginMetadataProcessMemoLifecycleClear(() => { - officialCatalogEntriesByPath.clear(); - externalCatalogEntriesByPath.clear(); -}); +registerPluginMetadataProcessMemoLifecycleClear(() => catalogEntriesByPath.clear()); type ManifestKey = typeof MANIFEST_KEY; function parseCatalogEntries(raw: unknown): ExternalCatalogEntry[] { - if (Array.isArray(raw)) { - return raw.filter((entry): entry is ExternalCatalogEntry => isRecord(entry)); - } - if (!isRecord(raw)) { - return []; - } - const list = raw.entries ?? raw.packages ?? raw.plugins; - if (!Array.isArray(list)) { - return []; - } - return list.filter((entry): entry is ExternalCatalogEntry => isRecord(entry)); -} - -function splitEnvPaths(value: string): string[] { - const trimmed = value.trim(); - if (!trimmed) { - return []; - } - return normalizeStringEntries( - trimmed.split(/[;,]/g).flatMap((chunk) => chunk.split(path.delimiter)), - ); -} - -function resolveDefaultCatalogPaths(env: NodeJS.ProcessEnv): string[] { - const configDir = resolveConfigDir(env); - return [ - path.join(configDir, "mpm", "plugins.json"), - path.join(configDir, "mpm", "catalog.json"), - path.join(configDir, "plugins", "catalog.json"), - ]; + const list = Array.isArray(raw) + ? raw + : isRecord(raw) + ? (raw.entries ?? raw.packages ?? raw.plugins) + : undefined; + return Array.isArray(list) + ? list.filter((entry): entry is ExternalCatalogEntry => isRecord(entry)) + : []; } function resolveExternalCatalogPaths(options: CatalogOptions): string[] { @@ -165,23 +134,16 @@ function resolveExternalCatalogPaths(options: CatalogOptions): string[] { const env = options.env ?? process.env; for (const key of ENV_CATALOG_PATHS) { const raw = env[key]; - if (raw && raw.trim()) { - return splitEnvPaths(raw); + if (raw?.trim()) { + return normalizeStringEntries( + raw.split(/[;,]/g).flatMap((chunk) => chunk.split(path.delimiter)), + ); } } - return resolveDefaultCatalogPaths(env); -} - -function loadExternalCatalogEntries(options: CatalogOptions): ExternalCatalogEntry[] { - const paths = resolveExternalCatalogPaths(options).map((rawPath) => - resolveUserPath(rawPath, options.env ?? process.env), + const configDir = resolveConfigDir(env); + return ["mpm/plugins.json", "mpm/catalog.json", "plugins/catalog.json"].map((relativePath) => + path.join(configDir, relativePath), ); - return loadCatalogEntriesFromPaths(paths, externalCatalogEntriesByPath); -} - -function readCatalogEntriesFromPath(resolvedPath: string): ExternalCatalogEntry[] | null { - const payload = tryReadJsonSync(resolvedPath); - return payload === null ? null : parseCatalogEntries(payload); } function loadCatalogEntriesFromPaths( @@ -190,19 +152,15 @@ function loadCatalogEntriesFromPaths( ): ExternalCatalogEntry[] { const entries: ExternalCatalogEntry[] = []; for (const resolvedPath of paths) { - if (cache?.has(resolvedPath)) { - const cached = cache.get(resolvedPath); - if (cached) { - entries.push(...cached); - } - continue; + let parsed = cache?.get(resolvedPath); + if (parsed === undefined) { + const payload = tryReadJsonSync(resolvedPath); + parsed = payload === null ? null : parseCatalogEntries(payload); + cache?.set(resolvedPath, parsed); } - const parsed = readCatalogEntriesFromPath(resolvedPath); - cache?.set(resolvedPath, parsed); - if (parsed === null) { - continue; + if (parsed !== null) { + entries.push(...parsed); } - entries.push(...parsed); } return entries; } @@ -232,49 +190,6 @@ function resolveOfficialCatalogPaths(options: CatalogOptions): string[] { return uniqueStrings(candidates); } -function loadOfficialCatalogEntries(options: CatalogOptions): ChannelPluginCatalogEntry[] { - const builtInEntries = listOfficialExternalChannelCatalogEntries(); - const officialPaths = resolveOfficialCatalogPaths(options); - const fileEntries = loadCatalogEntriesFromPaths( - officialPaths, - options.officialCatalogPaths && options.officialCatalogPaths.length > 0 - ? undefined - : officialCatalogEntriesByPath, - ); - return [...builtInEntries, ...fileEntries] - .map((entry) => buildExternalCatalogEntry(entry, { trustedSourceLinkedOfficialInstall: true })) - .filter((entry): entry is ChannelPluginCatalogEntry => Boolean(entry)); -} - -function toChannelMeta(params: { - channel: NonNullable; - id: string; -}): ChannelMeta | null { - const label = params.channel.label?.trim(); - if (!label) { - return null; - } - const selectionLabel = params.channel.selectionLabel?.trim() || label; - const detailLabel = params.channel.detailLabel?.trim(); - const docsPath = params.channel.docsPath?.trim() || `/channels/${params.id}`; - const blurb = params.channel.blurb?.trim() || ""; - const systemImage = params.channel.systemImage?.trim(); - - return buildManifestChannelMeta({ - id: params.id, - channel: params.channel, - label, - selectionLabel, - docsPath, - docsLabel: normalizeOptionalString(params.channel.docsLabel), - blurb, - detailLabel, - ...(systemImage ? { systemImage } : {}), - arrayFieldMode: "defined", - selectionDocsPrefixMode: "truthy", - }); -} - function resolveInstallInfo(params: { install?: PluginPackageInstall; packageName?: string; @@ -306,18 +221,17 @@ function resolveInstallInfo(params: { localPath = path.relative(params.workspaceDir, params.packageDir) || undefined; } const requestedDefaultChoice = params.install?.defaultChoice; + const availableChoices = { clawhub: clawhubSpec, npm: npmSpec, local: localPath }; const defaultChoice: NonNullable = - requestedDefaultChoice === "clawhub" && clawhubSpec - ? "clawhub" - : requestedDefaultChoice === "npm" && npmSpec - ? "npm" - : requestedDefaultChoice === "local" && localPath + requestedDefaultChoice && + Object.hasOwn(availableChoices, requestedDefaultChoice) && + availableChoices[requestedDefaultChoice] + ? requestedDefaultChoice + : clawhubSpec + ? "clawhub" + : localPath ? "local" - : clawhubSpec - ? "clawhub" - : localPath - ? "local" - : "npm"; + : "npm"; const install = { ...(localPath ? { localPath } : {}), defaultChoice, @@ -356,28 +270,18 @@ function buildCatalogEntryFromManifest(params: { channel?: PluginPackageChannel; install?: PluginPackageInstall; }): ChannelPluginCatalogEntry | null { - if (!params.channel) { + const channel = params.channel; + const id = channel?.id?.trim(); + const label = channel?.label?.trim(); + if (!channel || !id || !label) { return null; } - const id = params.channel.id?.trim(); - if (!id) { - return null; - } - const meta = toChannelMeta({ channel: params.channel, id }); - if (!meta) { - return null; - } - const install = resolveInstallInfo({ - install: params.install, - packageName: params.packageName, - packageVersion: params.packageVersion, - packageDir: params.packageDir, - workspaceDir: params.workspaceDir, - }); + const install = resolveInstallInfo(params); if (!install) { return null; } const pluginId = normalizeOptionalString(params.pluginId); + const systemImage = channel.systemImage?.trim(); return { id, ...(pluginId ? { pluginId } : {}), @@ -385,8 +289,20 @@ function buildCatalogEntryFromManifest(params: { ...(params.trustedSourceLinkedOfficialInstall ? { trustedSourceLinkedOfficialInstall: true } : {}), - channel: params.channel, - meta, + channel, + meta: buildManifestChannelMeta({ + id, + channel, + label, + selectionLabel: channel.selectionLabel?.trim() || label, + docsPath: channel.docsPath?.trim() || `/channels/${id}`, + docsLabel: normalizeOptionalString(channel.docsLabel), + blurb: channel.blurb?.trim() || "", + detailLabel: channel.detailLabel?.trim(), + ...(systemImage ? { systemImage } : {}), + arrayFieldMode: "defined", + selectionDocsPrefixMode: "truthy", + }), install, installSource: describePluginInstallSource(install, { expectedPackageName: params.packageName, @@ -396,16 +312,14 @@ function buildCatalogEntryFromManifest(params: { function buildExternalCatalogEntry( entry: ExternalCatalogEntry, - options?: { - trustedSourceLinkedOfficialInstall?: boolean; - }, + trustedSourceLinkedOfficialInstall = false, ): ChannelPluginCatalogEntry | null { const manifest = entry[MANIFEST_KEY]; return buildCatalogEntryFromManifest({ pluginId: manifest?.plugin?.id, packageName: entry.name, packageVersion: entry.version, - trustedSourceLinkedOfficialInstall: options?.trustedSourceLinkedOfficialInstall, + trustedSourceLinkedOfficialInstall, channel: manifest?.channel, install: manifest?.install, }); @@ -467,10 +381,7 @@ export function listRawChannelPluginCatalogEntries( }; for (const candidate of manifestEntries) { - if ( - shouldExcludeCatalogOrigin(options, candidate.origin) || - shouldExcludeCatalogPlugin(options, candidate.pluginId, candidate.origin) - ) { + if (shouldExcludeCatalogEntry(options, candidate.pluginId, candidate.origin)) { continue; } const entry = buildCatalogEntryFromManifest({ @@ -488,18 +399,35 @@ export function listRawChannelPluginCatalogEntries( rememberCatalogEntry(entry, ORIGIN_PRIORITY[candidate.origin] ?? 99); } - for (const entry of loadOfficialCatalogEntries(options)) { - rememberCatalogEntry(entry, FALLBACK_CATALOG_PRIORITY); - } + const rememberExternalCatalogEntries = ( + entries: ExternalCatalogEntry[], + priority: number, + trustedSourceLinkedOfficialInstall = false, + ) => { + for (const candidate of entries) { + const entry = buildExternalCatalogEntry(candidate, trustedSourceLinkedOfficialInstall); + if (entry) { + rememberCatalogEntry(entry, priority); + } + } + }; + const officialFileEntries = loadCatalogEntriesFromPaths( + resolveOfficialCatalogPaths(options), + options.officialCatalogPaths?.length ? undefined : catalogEntriesByPath, + ); + rememberExternalCatalogEntries( + [...listOfficialExternalChannelCatalogEntries(), ...officialFileEntries], + FALLBACK_CATALOG_PRIORITY, + true, + ); - const externalEntries = loadExternalCatalogEntries(options) - .map((entry) => buildExternalCatalogEntry(entry)) - .filter((entry): entry is ChannelPluginCatalogEntry => Boolean(entry)); - for (const entry of externalEntries) { - // External catalogs are the supported override seam for shipped fallback - // metadata, but discovered plugins should still win when they are present. - rememberCatalogEntry(entry, EXTERNAL_CATALOG_PRIORITY); - } + const externalCatalogPaths = resolveExternalCatalogPaths(options).map((rawPath) => + resolveUserPath(rawPath, options.env ?? process.env), + ); + const externalEntries = loadCatalogEntriesFromPaths(externalCatalogPaths, catalogEntriesByPath); + // External catalogs are the supported override seam for shipped fallback + // metadata, but discovered plugins should still win when they are present. + rememberExternalCatalogEntries(externalEntries, EXTERNAL_CATALOG_PRIORITY); return Array.from(resolved.values()) .map(({ entry }) => entry) diff --git a/src/channels/plugins/read-only.test.ts b/src/channels/plugins/read-only.test.ts index 832cf280f47e..495aff01ac2c 100644 --- a/src/channels/plugins/read-only.test.ts +++ b/src/channels/plugins/read-only.test.ts @@ -1,7 +1,6 @@ // Read-only channel tests cover read-only plugin registration and runtime behavior. import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { cleanupPluginLoaderFixturesForTest, @@ -17,7 +16,6 @@ import { createTestRegistry, } from "../../test-utils/channel-plugins.js"; import { - listPluginLoaderModuleCandidateUrls, listReadOnlyChannelPluginsForConfig, resolveReadOnlyChannelPluginsForConfig, } from "./read-only.js"; @@ -54,11 +52,6 @@ function createExternalChannelTestConfig(params: { }; } -function modulePathEndsWith(modulePath: string, suffix: string): boolean { - const normalized = modulePath.startsWith("file:") ? fileURLToPath(modulePath) : modulePath; - return normalized.replace(/\\/g, "/").endsWith(suffix); -} - function expectRecordFields(record: unknown, expected: Record) { if (!record || typeof record !== "object") { throw new Error("Expected record"); @@ -82,96 +75,6 @@ vi.mock("../../plugins/bundled-dir.js", async (importOriginal) => { vi.mock("../../plugins/plugin-module-loader-cache.js", async (importOriginal) => { const actual = await importOriginal(); - const { createRequire } = await import("node:module"); - const require = createRequire(import.meta.url); - - type LoaderConfig = { - plugins?: { - load?: { paths?: unknown }; - }; - }; - type LoaderParams = { - config?: LoaderConfig; - onlyPluginIds?: readonly string[]; - workspaceDir?: string; - }; - - function readJson(filePath: string): unknown { - return JSON.parse(fs.readFileSync(filePath, "utf-8")); - } - - function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); - } - - function listCandidatePluginDirs(params: LoaderParams): string[] { - const paths = params.config?.plugins?.load?.paths; - const explicitPaths = Array.isArray(paths) - ? paths.filter((entry): entry is string => typeof entry === "string") - : []; - const workspaceExtensionsDir = params.workspaceDir - ? path.join(params.workspaceDir, ".openclaw", "extensions") - : undefined; - if (!workspaceExtensionsDir || !fs.existsSync(workspaceExtensionsDir)) { - return explicitPaths; - } - return explicitPaths.concat( - fs - .readdirSync(workspaceExtensionsDir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(workspaceExtensionsDir, entry.name)), - ); - } - - function loadOpenClawPlugins(params: LoaderParams) { - const onlyPluginIds = new Set(params.onlyPluginIds ?? []); - const diagnostics: Array<{ - level: "error"; - pluginId: string; - source: string; - message: string; - }> = []; - const channelSetups = listCandidatePluginDirs(params).flatMap((pluginDir) => { - const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); - const packagePath = path.join(pluginDir, "package.json"); - if (!fs.existsSync(manifestPath) || !fs.existsSync(packagePath)) { - return []; - } - const manifest = readJson(manifestPath); - if (!isRecord(manifest) || typeof manifest.id !== "string") { - return []; - } - if (onlyPluginIds.size > 0 && !onlyPluginIds.has(manifest.id)) { - return []; - } - const packageJson = readJson(packagePath); - const openclaw = isRecord(packageJson) ? packageJson.openclaw : undefined; - const setupEntry = isRecord(openclaw) ? openclaw.setupEntry : undefined; - if (typeof setupEntry !== "string") { - return []; - } - const setupPath = path.join(pluginDir, setupEntry); - let setupModule: unknown; - try { - setupModule = require(setupPath); - } catch (error) { - diagnostics.push({ - level: "error", - pluginId: manifest.id, - source: setupPath, - message: `failed to load setup entry: ${String(error)}`, - }); - return []; - } - const entry = ((setupModule as { default?: unknown }).default ?? setupModule) as { - plugin?: unknown; - }; - const plugin = entry.plugin; - return plugin ? [{ pluginId: manifest.id, plugin }] : []; - }); - return { channelSetups, diagnostics }; - } - return { ...actual, getCachedPluginModuleLoader: ((params) => { @@ -179,16 +82,7 @@ vi.mock("../../plugins/plugin-module-loader-cache.js", async (importOriginal) => modulePath: params.modulePath, tryNative: params.tryNative, }); - const actualLoader = actual.getCachedPluginModuleLoader(params); - return ((modulePath: string) => { - if ( - modulePathEndsWith(modulePath, "/plugins/loader.js") || - modulePathEndsWith(modulePath, "/plugins/loader.ts") - ) { - return { loadOpenClawPlugins }; - } - return actualLoader(modulePath); - }) as ReturnType; + return actual.getCachedPluginModuleLoader(params); }) satisfies typeof actual.getCachedPluginModuleLoader, }; }); @@ -510,20 +404,6 @@ afterAll(() => { }); describe("listReadOnlyChannelPluginsForConfig", () => { - it("keeps built plugin loader candidates inside the installed package dist root", () => { - const packageRoot = path.join(makeTempDir(), "node_modules", "openclaw"); - const importerPath = path.join(packageRoot, "dist", "read-only-B4EkEtUx.js"); - const candidates = listPluginLoaderModuleCandidateUrls(pathToFileURL(importerPath).href).map( - (candidate) => fileURLToPath(candidate), - ); - - expect(candidates).toEqual([ - path.join(packageRoot, "dist", "plugins", "loader.js"), - path.join(packageRoot, "dist", "plugins", "build-smoke-entry.js"), - ]); - expect(candidates).not.toContain(path.join(packageRoot, "..", "plugins", "loader.js")); - }); - it("uses package channel metadata without loading setup or full runtime", () => { const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin(); const plugins = listReadOnlyChannelPluginsForConfig( @@ -551,14 +431,10 @@ describe("listReadOnlyChannelPluginsForConfig", () => { ); expectExternalChatSetupOnlyPluginLoaded({ plugins, setupMarker, fullMarker }); - expect( - moduleLoaderParams.some( - (entry) => - entry.tryNative === true && - (modulePathEndsWith(entry.modulePath, "/plugins/loader.js") || - modulePathEndsWith(entry.modulePath, "/plugins/loader.ts")), - ), - ).toBe(true); + expect(moduleLoaderParams).toContainEqual({ + modulePath: path.join(pluginDir, "setup-entry.cjs"), + tryNative: true, + }); }); it("uses activation source config to discover channel setup metadata after secret stripping", () => { diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index 7dd48df94b10..803add7768b7 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -4,8 +4,6 @@ * Builds lightweight channel plugin views from config, manifests, and setup metadata. */ import { createHash } from "node:crypto"; -import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; import { sortUniqueStrings, uniqueStrings, @@ -27,7 +25,6 @@ import { resolveSetupChannelRegistration, } from "../../plugins/loader-channel-setup.js"; import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; -import type { PluginDiagnostic } from "../../plugins/manifest-types.js"; import { registerPluginMetadataProcessMemoLifecycleClear } from "../../plugins/plugin-metadata-lifecycle.js"; import { resolvePluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js"; import { @@ -52,92 +49,9 @@ import { import { listChannelPlugins } from "./registry.js"; import type { ChannelPlugin } from "./types.plugin.js"; -const SOURCE_PLUGIN_LOADER_MODULE_CANDIDATES = [ - "../../plugins/loader.js", - "../../plugins/loader.ts", -] as const; -const BUILT_PLUGIN_LOADER_MODULE_CANDIDATES = [ - "plugins/loader.js", - "plugins/build-smoke-entry.js", -] as const; const moduleLoaders: PluginModuleLoaderCache = new Map(); const log = createSubsystemLogger("channels"); -type PluginLoaderModule = { - loadOpenClawPlugins: (params: { - config: OpenClawConfig; - activationSourceConfig?: OpenClawConfig; - env?: NodeJS.ProcessEnv; - workspaceDir?: string; - cache?: boolean; - activate?: boolean; - includeSetupOnlyChannelPlugins?: boolean; - forceSetupOnlyChannelPlugins?: boolean; - requireSetupEntryForSetupOnlyChannelPlugins?: boolean; - onlyPluginIds?: readonly string[]; - }) => { - channelSetups: Iterable<{ - pluginId: string; - plugin: ChannelPlugin; - }>; - diagnostics?: readonly PluginDiagnostic[]; - }; -}; - -let pluginLoaderModule: PluginLoaderModule | undefined; - -function listBuiltPluginLoaderModuleCandidateUrls(importerUrl: string): URL[] { - let importerPath: string; - try { - importerPath = fileURLToPath(importerUrl); - } catch { - return []; - } - const distMarker = `${path.sep}dist${path.sep}`; - const distMarkerIndex = importerPath.lastIndexOf(distMarker); - if (distMarkerIndex < 0) { - return []; - } - // Bundled read-only chunks live under dist/ with hashed names. Source-relative - // ../../plugins candidates would escape the installed openclaw package there. - const distRoot = importerPath.slice(0, distMarkerIndex + distMarker.length - 1); - return BUILT_PLUGIN_LOADER_MODULE_CANDIDATES.map((candidate) => - pathToFileURL(path.join(distRoot, candidate)), - ); -} - -export function listPluginLoaderModuleCandidateUrls(importerUrl = import.meta.url): URL[] { - const builtCandidates = listBuiltPluginLoaderModuleCandidateUrls(importerUrl); - if (builtCandidates.length > 0) { - return builtCandidates; - } - return SOURCE_PLUGIN_LOADER_MODULE_CANDIDATES.map((candidate) => new URL(candidate, importerUrl)); -} - -function loadPluginLoaderModule(): PluginLoaderModule { - if (pluginLoaderModule) { - return pluginLoaderModule; - } - for (const candidate of listPluginLoaderModuleCandidateUrls()) { - const modulePath = fileURLToPath(candidate); - try { - const moduleLoader = getCachedPluginModuleLoader({ - cache: moduleLoaders, - modulePath, - importerUrl: import.meta.url, - preferBuiltDist: true, - loaderFilename: import.meta.url, - tryNative: true, - }); - pluginLoaderModule = moduleLoader(modulePath) as PluginLoaderModule; - return pluginLoaderModule; - } catch { - // Try built/runtime source candidates in order. - } - } - throw new Error("Could not load plugin runtime loader for channel setup fallback."); -} - type ReadOnlyChannelPluginOptions = { env?: NodeJS.ProcessEnv; stateDir?: string; @@ -549,7 +463,7 @@ function loadSetupChannelPluginFromManifestRecord(params: { ) { return {}; } - return { plugin: cloneChannelPluginForChannelId(registration.plugin, params.channelId) }; + return { plugin: registration.plugin }; } catch (error) { const detail = formatErrorMessage(error); log.warn(`[channels] failed to load channel setup ${params.record.id}: ${detail}`); @@ -564,40 +478,6 @@ function loadSetupChannelPluginFromManifestRecord(params: { } } -function collectChannelPluginLoadFailuresFromDiagnostics(params: { - diagnostics: readonly PluginDiagnostic[] | undefined; - records: readonly PluginManifestRecord[]; - channelIds: readonly string[]; -}): ReadOnlyChannelPluginLoadFailure[] { - if (!params.diagnostics?.length || params.channelIds.length === 0) { - return []; - } - const configuredChannelIds = new Set(params.channelIds); - const recordsByPluginId = new Map(params.records.map((record) => [record.id, record] as const)); - const failures: ReadOnlyChannelPluginLoadFailure[] = []; - for (const diagnostic of params.diagnostics) { - if (diagnostic.level !== "error" || !diagnostic.pluginId) { - continue; - } - const record = recordsByPluginId.get(diagnostic.pluginId); - if (!record) { - continue; - } - for (const channelId of record.channels) { - if (!configuredChannelIds.has(channelId)) { - continue; - } - failures.push({ - channelId, - pluginId: record.id, - source: diagnostic.source, - message: diagnostic.message, - }); - } - } - return failures; -} - function rebindChannelPluginConfig( config: ChannelPlugin["config"], sourceChannelId: string, @@ -727,43 +607,6 @@ function cloneChannelPluginForChannelId(plugin: ChannelPlugin, channelId: string }; } -function addSetupChannelPlugins( - byId: Map, - setups: Iterable<{ - pluginId: string; - plugin: ChannelPlugin; - }>, - options: { - ownedChannelIdsByPluginId: ReadonlyMap; - ownedMissingChannelIdsByPluginId: ReadonlyMap; - }, -): void { - for (const setup of setups) { - const ownedMissingChannelIds = options.ownedMissingChannelIdsByPluginId - .get(setup.pluginId) - ?.filter(isSafeManifestChannelId); - if (!ownedMissingChannelIds || ownedMissingChannelIds.length === 0) { - continue; - } - const ownedChannelIds = (options.ownedChannelIdsByPluginId.get(setup.pluginId) ?? []).filter( - isSafeManifestChannelId, - ); - if (setup.plugin.id !== setup.pluginId && !ownedChannelIds.includes(setup.plugin.id)) { - continue; - } - addChannelPlugins( - byId, - ownedMissingChannelIds.map((channelId) => - cloneChannelPluginForChannelId(setup.plugin, channelId), - ), - { - onlyIds: new Set(ownedMissingChannelIds), - allowOverwrite: false, - }, - ); - } -} - function addManifestChannelPlugins( byId: Map, records: readonly PluginManifestRecord[], @@ -930,7 +773,9 @@ export function resolveReadOnlyChannelPluginsForConfig( const bundledSetupPlugin = setupResults.map((result) => result.plugin).find((plugin) => plugin) ?? getBundledChannelSetupPlugin(channelId, env); - addChannelPlugins(byId, [bundledSetupPlugin]); + addChannelPlugins(byId, [ + bundledSetupPlugin && cloneChannelPluginForChannelId(bundledSetupPlugin, channelId), + ]); } } @@ -962,45 +807,41 @@ export function resolveReadOnlyChannelPluginsForConfig( }); if (externalPluginIds.length > 0) { const externalPluginIdSet = new Set(externalPluginIds); - const ownedChannelIdsByPluginId = new Map( - externalManifestRecords - .filter((record) => externalPluginIdSet.has(record.id)) - .map((record) => [record.id, record.channels] as const), - ); - if (missingConfiguredChannelIds.length > 0 && options.includeSetupFallbackPlugins === true) { + if (options.includeSetupFallbackPlugins === true) { const missingChannelIdSet = new Set(missingConfiguredChannelIds); - const ownedMissingChannelIdsByPluginId = new Map( - [...ownedChannelIdsByPluginId].map( - ([pluginId, channelIds]) => - [ - pluginId, - channelIds.filter((channelId) => missingChannelIdSet.has(channelId)), - ] as const, - ), - ); - const registry = loadPluginLoaderModule().loadOpenClawPlugins({ - config: cfg, - activationSourceConfig: options.activationSourceConfig ?? cfg, - env, - workspaceDir, - cache: false, - activate: false, - includeSetupOnlyChannelPlugins: true, - forceSetupOnlyChannelPlugins: true, - requireSetupEntryForSetupOnlyChannelPlugins: true, - onlyPluginIds: externalPluginIds, - }); - loadFailures.push( - ...collectChannelPluginLoadFailuresFromDiagnostics({ - diagnostics: registry.diagnostics, - records: externalManifestRecords, - channelIds: missingConfiguredChannelIds, - }), - ); - addSetupChannelPlugins(byId, registry.channelSetups, { - ownedChannelIdsByPluginId, - ownedMissingChannelIdsByPluginId, - }); + for (const record of externalManifestRecords) { + if (!externalPluginIdSet.has(record.id) || !record.setupSource) { + continue; + } + const ownedMissingChannelIds = record.channels.filter( + (channelId) => missingChannelIdSet.has(channelId) && !byId.has(channelId), + ); + const firstChannelId = ownedMissingChannelIds[0]; + if (!firstChannelId) { + continue; + } + const setupResult = loadSetupChannelPluginFromManifestRecord({ + record, + channelId: firstChannelId, + }); + const failure = setupResult.failure; + if (failure) { + loadFailures.push( + ...ownedMissingChannelIds.map((channelId) => ({ ...failure, channelId })), + ); + continue; + } + const plugin = setupResult.plugin; + if (plugin) { + addChannelPlugins( + byId, + ownedMissingChannelIds.map((channelId) => + cloneChannelPluginForChannelId(plugin, channelId), + ), + { allowOverwrite: false }, + ); + } + } } const externalManifestMissingChannelIds = missingConfiguredChannelIds.filter( (channelId) => !byId.has(channelId),