mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 04:31:39 +00:00
refactor(channels): consolidate lightweight plugin discovery (#117541)
This commit is contained in:
committed by
GitHub
parent
b67fa6a2c4
commit
5da998fe8a
@@ -77,30 +77,35 @@ type BundledChannelPackageSetupFeature =
|
||||
| "legacyStateMigrations"
|
||||
| "legacySessionSurfaces";
|
||||
|
||||
type GeneratedBundledChannelEntry = {
|
||||
id: string;
|
||||
type BundledChannelArtifactValues = {
|
||||
entry: BundledChannelEntryRuntimeContract;
|
||||
setupEntry: BundledChannelSetupEntryRuntimeContract;
|
||||
plugin: ChannelPlugin;
|
||||
setupPlugin: ChannelPlugin;
|
||||
secrets: NonNullable<ChannelPlugin["secrets"]>;
|
||||
setupSecrets: NonNullable<ChannelPlugin["secrets"]>;
|
||||
accountInspector: NonNullable<ChannelPlugin["config"]["inspectAccount"]>;
|
||||
};
|
||||
|
||||
type BundledChannelArtifactKind = keyof BundledChannelArtifactValues;
|
||||
type BundledChannelEntryKind = "entry" | "setupEntry";
|
||||
type BundledChannelArtifacts = Partial<{
|
||||
[Kind in BundledChannelArtifactKind]: BundledChannelArtifactValues[Kind] | null;
|
||||
}>;
|
||||
|
||||
type BundledChannelLoadContext = {
|
||||
pluginLoadInProgressIds: Set<ChannelId>;
|
||||
setupPluginLoadInProgressIds: Set<ChannelId>;
|
||||
entryLoadInProgressIds: Set<ChannelId>;
|
||||
setupEntryLoadInProgressIds: Set<ChannelId>;
|
||||
lazyEntriesById: Map<ChannelId, GeneratedBundledChannelEntry | null>;
|
||||
lazySetupEntriesById: Map<ChannelId, BundledChannelSetupEntryRuntimeContract | null>;
|
||||
lazyPluginsById: Map<ChannelId, ChannelPlugin | null>;
|
||||
lazySetupPluginsById: Map<ChannelId, ChannelPlugin | null>;
|
||||
lazySecretsById: Map<ChannelId, ChannelPlugin["secrets"] | null>;
|
||||
lazySetupSecretsById: Map<ChannelId, ChannelPlugin["secrets"] | null>;
|
||||
lazyAccountInspectorsById: Map<
|
||||
ChannelId,
|
||||
NonNullable<ChannelPlugin["config"]["inspectAccount"]> | null
|
||||
>;
|
||||
artifactLoadsInProgress: Set<string>;
|
||||
artifactsById: Map<ChannelId, BundledChannelArtifacts>;
|
||||
metadataById: Map<ChannelId, BundledChannelPluginMetadata | null>;
|
||||
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<string, BundledChannelLoadConte
|
||||
const bundledChannelBoundaryRoots = new Map<string, string>();
|
||||
const sourceBundledEntryLoaderCache: PluginModuleLoaderCache = new Map();
|
||||
|
||||
function rememberBoundedBundledChannelValue<TKey, TValue>(
|
||||
cache: Map<TKey, TValue>,
|
||||
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<TKind extends BundledChannelEntryKind>(
|
||||
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<BundledChannelEntryRuntimeContract>;
|
||||
if (record.kind !== "bundled-channel-entry") {
|
||||
const record = resolved as Record<string, unknown>;
|
||||
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<BundledChannelSetupEntryRuntimeContract>;
|
||||
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<BundledChannelSetupEntryRuntimeContract["features"]>,
|
||||
): 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<TKind extends BundledChannelEntryKind>(
|
||||
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<BundledChannelSetupEntryRuntimeContract["features"]>,
|
||||
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<TKind extends BundledChannelArtifactKind>(
|
||||
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<TKind extends BundledChannelArtifactKind>(
|
||||
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<ChannelId>([
|
||||
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<BundledChannelArtifactKind, string> = {
|
||||
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<ChannelPlugin["config"]["inspectAccount"]> | 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<ChannelId>([
|
||||
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<TArtifact>(
|
||||
feature: keyof NonNullable<BundledChannelSetupEntryRuntimeContract["features"]>,
|
||||
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<ChannelPlugin["config"]["inspectAccount"]> | 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}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -79,28 +79,23 @@ const ORIGIN_PRIORITY: Record<PluginOrigin, number> = {
|
||||
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<string, ExternalCatalogEntry[] | null>();
|
||||
const externalCatalogEntriesByPath = new Map<string, ExternalCatalogEntry[] | null>();
|
||||
const catalogEntriesByPath = new Map<string, ExternalCatalogEntry[] | null>();
|
||||
|
||||
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<OpenClawPackageManifest["channel"]>;
|
||||
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<PluginPackageInstall["defaultChoice"]> =
|
||||
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)
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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<typeof import("../../plugins/plugin-module-loader-cache.js")>();
|
||||
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<string, unknown> {
|
||||
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<typeof actual.getCachedPluginModuleLoader>;
|
||||
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", () => {
|
||||
|
||||
@@ -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<string, ChannelPlugin>,
|
||||
setups: Iterable<{
|
||||
pluginId: string;
|
||||
plugin: ChannelPlugin;
|
||||
}>,
|
||||
options: {
|
||||
ownedChannelIdsByPluginId: ReadonlyMap<string, readonly string[]>;
|
||||
ownedMissingChannelIdsByPluginId: ReadonlyMap<string, readonly string[]>;
|
||||
},
|
||||
): 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<string, ChannelPlugin>,
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user