From d0d82ea67bf2ebbbf96ff184d4bbcdbc28b6428f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Mar 2026 00:52:03 +0000 Subject: [PATCH] test: trim import-heavy startup paths --- .../check-extension-plugin-sdk-boundary.mjs | 71 +++-- .../check-web-search-provider-boundaries.mjs | 51 ++-- src/commands/onboard-channels.ts | 2 +- src/config/channel-configured.ts | 159 ++++++++++ src/config/plugin-auto-enable.ts | 285 +++++++----------- .../bundled/session-memory/handler.test.ts | 3 +- src/plugins/loader.test.ts | 48 +-- src/plugins/loader.ts | 2 +- src/plugins/manifest-registry.ts | 14 + 9 files changed, 367 insertions(+), 268 deletions(-) create mode 100644 src/config/channel-configured.ts diff --git a/scripts/check-extension-plugin-sdk-boundary.mjs b/scripts/check-extension-plugin-sdk-boundary.mjs index 4e21b86021f..1915b80a5da 100644 --- a/scripts/check-extension-plugin-sdk-boundary.mjs +++ b/scripts/check-extension-plugin-sdk-boundary.mjs @@ -35,7 +35,7 @@ const baselinePathByMode = { ), }; -const inventoryPromiseByMode = new Map(); +let allInventoryByModePromise; let parsedExtensionSourceFilesPromise; const ruleTextByMode = { @@ -193,30 +193,42 @@ function shouldReport(mode, resolvedPath) { return !resolvedPath.startsWith("src/plugin-sdk/"); } -function collectFromSourceFile(mode, sourceFile, filePath) { - const entries = []; +function collectEntriesByModeFromSourceFile(sourceFile, filePath) { + const entriesByMode = { + "src-outside-plugin-sdk": [], + "plugin-sdk-internal": [], + "relative-outside-package": [], + }; const extensionRoot = resolveExtensionRoot(filePath); function push(kind, specifierNode, specifier) { const resolvedPath = resolveSpecifier(specifier, filePath); - if (mode === "relative-outside-package") { - if (!specifier.startsWith(".") || !resolvedPath || !extensionRoot) { - return; - } - if (resolvedPath === extensionRoot || resolvedPath.startsWith(`${extensionRoot}/`)) { - return; - } - } else if (!shouldReport(mode, resolvedPath)) { - return; - } - entries.push({ + const baseEntry = { file: normalizePath(filePath), line: toLine(sourceFile, specifierNode), kind, specifier, resolvedPath, - reason: classifyReason(mode, kind, resolvedPath, specifier), - }); + }; + + if (specifier.startsWith(".") && resolvedPath && extensionRoot) { + if (!(resolvedPath === extensionRoot || resolvedPath.startsWith(`${extensionRoot}/`))) { + entriesByMode["relative-outside-package"].push({ + ...baseEntry, + reason: classifyReason("relative-outside-package", kind, resolvedPath, specifier), + }); + } + } + + for (const mode of ["src-outside-plugin-sdk", "plugin-sdk-internal"]) { + if (!shouldReport(mode, resolvedPath)) { + continue; + } + entriesByMode[mode].push({ + ...baseEntry, + reason: classifyReason(mode, kind, resolvedPath, specifier), + }); + } } function visit(node) { @@ -240,26 +252,35 @@ function collectFromSourceFile(mode, sourceFile, filePath) { } visit(sourceFile); - return entries; + return entriesByMode; } export async function collectExtensionPluginSdkBoundaryInventory(mode) { if (!MODES.has(mode)) { throw new Error(`Unknown mode: ${mode}`); } - let pending = inventoryPromiseByMode.get(mode); - if (!pending) { - pending = (async () => { + if (!allInventoryByModePromise) { + allInventoryByModePromise = (async () => { const files = await collectParsedExtensionSourceFiles(); - const inventory = []; + const inventoryByMode = { + "src-outside-plugin-sdk": [], + "plugin-sdk-internal": [], + "relative-outside-package": [], + }; for (const { filePath, sourceFile } of files) { - inventory.push(...collectFromSourceFile(mode, sourceFile, filePath)); + const entriesByMode = collectEntriesByModeFromSourceFile(sourceFile, filePath); + for (const inventoryMode of MODES) { + inventoryByMode[inventoryMode].push(...entriesByMode[inventoryMode]); + } } - return inventory.toSorted(compareEntries); + for (const inventoryMode of MODES) { + inventoryByMode[inventoryMode] = inventoryByMode[inventoryMode].toSorted(compareEntries); + } + return inventoryByMode; })(); - inventoryPromiseByMode.set(mode, pending); } - return await pending; + const inventoryByMode = await allInventoryByModePromise; + return inventoryByMode[mode]; } export async function readExpectedInventory(mode) { diff --git a/scripts/check-web-search-provider-boundaries.mjs b/scripts/check-web-search-provider-boundaries.mjs index 97496323935..914e58bc964 100644 --- a/scripts/check-web-search-provider-boundaries.mjs +++ b/scripts/check-web-search-provider-boundaries.mjs @@ -60,6 +60,8 @@ const ignoredFiles = new Set([ "src/secrets/runtime-web-tools.test.ts", ]); +let webSearchProviderInventoryPromise; + function normalizeRelativePath(filePath) { return path.relative(repoRoot, filePath).split(path.sep).join("/"); } @@ -185,32 +187,37 @@ function scanGenericCoreImports(lines, relativeFile, inventory) { } export async function collectWebSearchProviderBoundaryInventory() { - const inventory = []; - const files = ( - await Promise.all(scanRoots.map(async (root) => await walkFiles(path.join(repoRoot, root)))) - ) - .flat() - .toSorted((left, right) => - normalizeRelativePath(left).localeCompare(normalizeRelativePath(right)), - ); + if (!webSearchProviderInventoryPromise) { + webSearchProviderInventoryPromise = (async () => { + const inventory = []; + const files = ( + await Promise.all(scanRoots.map(async (root) => await walkFiles(path.join(repoRoot, root)))) + ) + .flat() + .toSorted((left, right) => + normalizeRelativePath(left).localeCompare(normalizeRelativePath(right)), + ); - for (const filePath of files) { - const relativeFile = normalizeRelativePath(filePath); - if (ignoredFiles.has(relativeFile) || relativeFile.includes(".test.")) { - continue; - } - const content = await fs.readFile(filePath, "utf8"); - const lines = content.split(/\r?\n/); + for (const filePath of files) { + const relativeFile = normalizeRelativePath(filePath); + if (ignoredFiles.has(relativeFile) || relativeFile.includes(".test.")) { + continue; + } + const content = await fs.readFile(filePath, "utf8"); + const lines = content.split(/\r?\n/); - if (relativeFile === "src/plugins/web-search-providers.ts") { - scanWebSearchProviderRegistry(lines, relativeFile, inventory); - continue; - } + if (relativeFile === "src/plugins/web-search-providers.ts") { + scanWebSearchProviderRegistry(lines, relativeFile, inventory); + continue; + } - scanGenericCoreImports(lines, relativeFile, inventory); + scanGenericCoreImports(lines, relativeFile, inventory); + } + + return inventory.toSorted(compareInventoryEntries); + })(); } - - return inventory.toSorted(compareInventoryEntries); + return await webSearchProviderInventoryPromise; } export async function readExpectedInventory() { diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index 514b1a8fa5e..87ee4d5ad70 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -12,8 +12,8 @@ import { listChatChannels, } from "../channels/registry.js"; import { formatCliCommand } from "../cli/command-format.js"; +import { isChannelConfigured } from "../config/channel-configured.js"; import type { OpenClawConfig } from "../config/config.js"; -import { isChannelConfigured } from "../config/plugin-auto-enable.js"; import type { DmPolicy } from "../config/types.js"; import { enablePluginInConfig } from "../plugins/enable.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; diff --git a/src/config/channel-configured.ts b/src/config/channel-configured.ts new file mode 100644 index 00000000000..53ef1051256 --- /dev/null +++ b/src/config/channel-configured.ts @@ -0,0 +1,159 @@ +import { hasAnyWhatsAppAuth } from "../../extensions/whatsapp/auth-presence.js"; +import { hasMeaningfulChannelConfig } from "../channels/config-presence.js"; +import { isRecord } from "../utils.js"; +import type { OpenClawConfig } from "./config.js"; + +function hasNonEmptyString(value: unknown): boolean { + return typeof value === "string" && value.trim().length > 0; +} + +function accountsHaveKeys(value: unknown, keys: readonly string[]): boolean { + if (!isRecord(value)) { + return false; + } + for (const account of Object.values(value)) { + if (!isRecord(account)) { + continue; + } + for (const key of keys) { + if (hasNonEmptyString(account[key])) { + return true; + } + } + } + return false; +} + +function resolveChannelConfig( + cfg: OpenClawConfig, + channelId: string, +): Record | null { + const channels = cfg.channels as Record | undefined; + const entry = channels?.[channelId]; + return isRecord(entry) ? entry : null; +} + +type StructuredChannelConfigSpec = { + envAny?: readonly string[]; + envAll?: readonly string[]; + stringKeys?: readonly string[]; + numberKeys?: readonly string[]; + accountStringKeys?: readonly string[]; +}; + +const STRUCTURED_CHANNEL_CONFIG_SPECS: Record = { + telegram: { + envAny: ["TELEGRAM_BOT_TOKEN"], + stringKeys: ["botToken", "tokenFile"], + accountStringKeys: ["botToken", "tokenFile"], + }, + discord: { + envAny: ["DISCORD_BOT_TOKEN"], + stringKeys: ["token"], + accountStringKeys: ["token"], + }, + irc: { + envAll: ["IRC_HOST", "IRC_NICK"], + stringKeys: ["host", "nick"], + accountStringKeys: ["host", "nick"], + }, + slack: { + envAny: ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_USER_TOKEN"], + stringKeys: ["botToken", "appToken", "userToken"], + accountStringKeys: ["botToken", "appToken", "userToken"], + }, + signal: { + stringKeys: ["account", "httpUrl", "httpHost", "cliPath"], + numberKeys: ["httpPort"], + accountStringKeys: ["account", "httpUrl", "httpHost", "cliPath"], + }, + imessage: { + stringKeys: ["cliPath"], + }, +}; + +function envHasAnyKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): boolean { + for (const key of keys) { + if (hasNonEmptyString(env[key])) { + return true; + } + } + return false; +} + +function envHasAllKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): boolean { + for (const key of keys) { + if (!hasNonEmptyString(env[key])) { + return false; + } + } + return keys.length > 0; +} + +function hasAnyNumberKeys(entry: Record, keys: readonly string[]): boolean { + for (const key of keys) { + if (typeof entry[key] === "number") { + return true; + } + } + return false; +} + +function isStructuredChannelConfigured( + cfg: OpenClawConfig, + channelId: string, + env: NodeJS.ProcessEnv, + spec: StructuredChannelConfigSpec, +): boolean { + if (spec.envAny && envHasAnyKeys(env, spec.envAny)) { + return true; + } + if (spec.envAll && envHasAllKeys(env, spec.envAll)) { + return true; + } + const entry = resolveChannelConfig(cfg, channelId); + if (!entry) { + return false; + } + if (spec.stringKeys && spec.stringKeys.some((key) => hasNonEmptyString(entry[key]))) { + return true; + } + if (spec.numberKeys && hasAnyNumberKeys(entry, spec.numberKeys)) { + return true; + } + if (spec.accountStringKeys && accountsHaveKeys(entry.accounts, spec.accountStringKeys)) { + return true; + } + return hasMeaningfulChannelConfig(entry); +} + +function isWhatsAppConfigured(cfg: OpenClawConfig): boolean { + if (hasAnyWhatsAppAuth(cfg)) { + return true; + } + const entry = resolveChannelConfig(cfg, "whatsapp"); + if (!entry) { + return false; + } + return hasMeaningfulChannelConfig(entry); +} + +function isGenericChannelConfigured(cfg: OpenClawConfig, channelId: string): boolean { + const entry = resolveChannelConfig(cfg, channelId); + return hasMeaningfulChannelConfig(entry); +} + +export function isChannelConfigured( + cfg: OpenClawConfig, + channelId: string, + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (channelId === "whatsapp") { + return isWhatsAppConfigured(cfg); + } + const spec = STRUCTURED_CHANNEL_CONFIG_SPECS[channelId]; + if (spec) { + return isStructuredChannelConfigured(cfg, channelId, env, spec); + } + return isGenericChannelConfigured(cfg, channelId); +} diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 908c4eed43d..1476e4cf900 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -1,10 +1,6 @@ -import { hasAnyWhatsAppAuth } from "../../extensions/whatsapp/auth-presence.js"; +import fs from "node:fs"; +import path from "node:path"; import { normalizeProviderId } from "../agents/model-selection.js"; -import { hasMeaningfulChannelConfig } from "../channels/config-presence.js"; -import { - getChannelPluginCatalogEntry, - listChannelPluginCatalogEntries, -} from "../channels/plugins/catalog.js"; import { getChatChannelMeta, listChatChannels, @@ -14,7 +10,8 @@ import { loadPluginManifestRegistry, type PluginManifestRegistry, } from "../plugins/manifest-registry.js"; -import { isRecord } from "../utils.js"; +import { isRecord, resolveConfigDir, resolveUserPath } from "../utils.js"; +import { isChannelConfigured } from "./channel-configured.js"; import type { OpenClawConfig } from "./config.js"; import { ensurePluginAllowlisted } from "./plugins-allowlist.js"; @@ -39,161 +36,7 @@ const PROVIDER_PLUGIN_IDS: Array<{ pluginId: string; providerId: string }> = [ { pluginId: "copilot-proxy", providerId: "copilot-proxy" }, { pluginId: "minimax", providerId: "minimax-portal" }, ]; - -function hasNonEmptyString(value: unknown): boolean { - return typeof value === "string" && value.trim().length > 0; -} - -function accountsHaveKeys(value: unknown, keys: readonly string[]): boolean { - if (!isRecord(value)) { - return false; - } - for (const account of Object.values(value)) { - if (!isRecord(account)) { - continue; - } - for (const key of keys) { - if (hasNonEmptyString(account[key])) { - return true; - } - } - } - return false; -} - -function resolveChannelConfig( - cfg: OpenClawConfig, - channelId: string, -): Record | null { - const channels = cfg.channels as Record | undefined; - const entry = channels?.[channelId]; - return isRecord(entry) ? entry : null; -} - -type StructuredChannelConfigSpec = { - envAny?: readonly string[]; - envAll?: readonly string[]; - stringKeys?: readonly string[]; - numberKeys?: readonly string[]; - accountStringKeys?: readonly string[]; -}; - -const STRUCTURED_CHANNEL_CONFIG_SPECS: Record = { - telegram: { - envAny: ["TELEGRAM_BOT_TOKEN"], - stringKeys: ["botToken", "tokenFile"], - accountStringKeys: ["botToken", "tokenFile"], - }, - discord: { - envAny: ["DISCORD_BOT_TOKEN"], - stringKeys: ["token"], - accountStringKeys: ["token"], - }, - irc: { - envAll: ["IRC_HOST", "IRC_NICK"], - stringKeys: ["host", "nick"], - accountStringKeys: ["host", "nick"], - }, - slack: { - envAny: ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_USER_TOKEN"], - stringKeys: ["botToken", "appToken", "userToken"], - accountStringKeys: ["botToken", "appToken", "userToken"], - }, - signal: { - stringKeys: ["account", "httpUrl", "httpHost", "cliPath"], - numberKeys: ["httpPort"], - accountStringKeys: ["account", "httpUrl", "httpHost", "cliPath"], - }, - imessage: { - stringKeys: ["cliPath"], - }, -}; - -function envHasAnyKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): boolean { - for (const key of keys) { - if (hasNonEmptyString(env[key])) { - return true; - } - } - return false; -} - -function envHasAllKeys(env: NodeJS.ProcessEnv, keys: readonly string[]): boolean { - for (const key of keys) { - if (!hasNonEmptyString(env[key])) { - return false; - } - } - return keys.length > 0; -} - -function hasAnyNumberKeys(entry: Record, keys: readonly string[]): boolean { - for (const key of keys) { - if (typeof entry[key] === "number") { - return true; - } - } - return false; -} - -function isStructuredChannelConfigured( - cfg: OpenClawConfig, - channelId: string, - env: NodeJS.ProcessEnv, - spec: StructuredChannelConfigSpec, -): boolean { - if (spec.envAny && envHasAnyKeys(env, spec.envAny)) { - return true; - } - if (spec.envAll && envHasAllKeys(env, spec.envAll)) { - return true; - } - const entry = resolveChannelConfig(cfg, channelId); - if (!entry) { - return false; - } - if (spec.stringKeys && spec.stringKeys.some((key) => hasNonEmptyString(entry[key]))) { - return true; - } - if (spec.numberKeys && hasAnyNumberKeys(entry, spec.numberKeys)) { - return true; - } - if (spec.accountStringKeys && accountsHaveKeys(entry.accounts, spec.accountStringKeys)) { - return true; - } - return hasMeaningfulChannelConfig(entry); -} - -function isWhatsAppConfigured(cfg: OpenClawConfig): boolean { - if (hasAnyWhatsAppAuth(cfg)) { - return true; - } - const entry = resolveChannelConfig(cfg, "whatsapp"); - if (!entry) { - return false; - } - return hasMeaningfulChannelConfig(entry); -} - -function isGenericChannelConfigured(cfg: OpenClawConfig, channelId: string): boolean { - const entry = resolveChannelConfig(cfg, channelId); - return hasMeaningfulChannelConfig(entry); -} - -export function isChannelConfigured( - cfg: OpenClawConfig, - channelId: string, - env: NodeJS.ProcessEnv = process.env, -): boolean { - if (channelId === "whatsapp") { - return isWhatsAppConfigured(cfg); - } - const spec = STRUCTURED_CHANNEL_CONFIG_SPECS[channelId]; - if (spec) { - return isStructuredChannelConfigured(cfg, channelId, env, spec); - } - return isGenericChannelConfigured(cfg, channelId); -} +const ENV_CATALOG_PATHS = ["OPENCLAW_PLUGIN_CATALOG_PATHS", "OPENCLAW_MPM_CATALOG_PATHS"]; function collectModelRefs(cfg: OpenClawConfig): string[] { const refs: string[] = []; @@ -297,6 +140,89 @@ function buildChannelToPluginIdMap(registry: PluginManifestRegistry): Map chunk.split(path.delimiter)) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function resolveExternalCatalogPaths(env: NodeJS.ProcessEnv): string[] { + for (const key of ENV_CATALOG_PATHS) { + const raw = env[key]; + if (raw && raw.trim()) { + return splitEnvPaths(raw); + } + } + const configDir = resolveConfigDir(env); + return [ + path.join(configDir, "mpm", "plugins.json"), + path.join(configDir, "mpm", "catalog.json"), + path.join(configDir, "plugins", "catalog.json"), + ]; +} + +function parseExternalCatalogChannelEntries(raw: unknown): ExternalCatalogChannelEntry[] { + const list = (() => { + if (Array.isArray(raw)) { + return raw; + } + if (!isRecord(raw)) { + return []; + } + const entries = raw.entries ?? raw.packages ?? raw.plugins; + return Array.isArray(entries) ? entries : []; + })(); + + const channels: ExternalCatalogChannelEntry[] = []; + for (const entry of list) { + if (!isRecord(entry) || !isRecord(entry.openclaw) || !isRecord(entry.openclaw.channel)) { + continue; + } + const channel = entry.openclaw.channel; + const id = typeof channel.id === "string" ? channel.id.trim() : ""; + if (!id) { + continue; + } + const preferOver = Array.isArray(channel.preferOver) + ? channel.preferOver.filter((value): value is string => typeof value === "string") + : []; + channels.push({ id, preferOver }); + } + return channels; +} + +function resolveExternalCatalogPreferOver(channelId: string, env: NodeJS.ProcessEnv): string[] { + for (const rawPath of resolveExternalCatalogPaths(env)) { + const resolved = resolveUserPath(rawPath, env); + if (!fs.existsSync(resolved)) { + continue; + } + try { + const payload = JSON.parse(fs.readFileSync(resolved, "utf-8")) as unknown; + const channel = parseExternalCatalogChannelEntries(payload).find( + (entry) => entry.id === channelId, + ); + if (channel) { + return channel.preferOver; + } + } catch { + // Ignore invalid catalog files. + } + } + return []; +} + function resolvePluginIdForChannel( channelId: string, channelToPluginId: ReadonlyMap, @@ -310,17 +236,12 @@ function resolvePluginIdForChannel( return channelToPluginId.get(channelId) ?? channelId; } -function listKnownChannelPluginIds(env: NodeJS.ProcessEnv): string[] { - return Array.from( - new Set([ - ...listChatChannels().map((meta) => meta.id), - ...listChannelPluginCatalogEntries({ env }).map((entry) => entry.id), - ]), - ); +function listKnownChannelPluginIds(): string[] { + return listChatChannels().map((meta) => meta.id); } -function collectCandidateChannelIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): string[] { - const channelIds = new Set(listKnownChannelPluginIds(env)); +function collectCandidateChannelIds(cfg: OpenClawConfig): string[] { + const channelIds = new Set(listKnownChannelPluginIds()); const configuredChannels = cfg.channels as Record | undefined; if (!configuredChannels || typeof configuredChannels !== "object") { return Array.from(channelIds); @@ -359,7 +280,7 @@ function resolveConfiguredPlugins( const changes: PluginEnableChange[] = []; // Build reverse map: channel ID → plugin ID from installed plugin manifests. const channelToPluginId = buildChannelToPluginIdMap(registry); - for (const channelId of collectCandidateChannelIds(cfg, env)) { + for (const channelId of collectCandidateChannelIds(cfg)) { const pluginId = resolvePluginIdForChannel(channelId, channelToPluginId); if (isChannelConfigured(cfg, channelId, env)) { changes.push({ pluginId, reason: `${channelId} configured` }); @@ -410,13 +331,22 @@ function isPluginDenied(cfg: OpenClawConfig, pluginId: string): boolean { return Array.isArray(deny) && deny.includes(pluginId); } -function resolvePreferredOverIds(pluginId: string, env: NodeJS.ProcessEnv): string[] { +function resolvePreferredOverIds( + pluginId: string, + env: NodeJS.ProcessEnv, + registry: PluginManifestRegistry, +): string[] { const normalized = normalizeChatChannelId(pluginId); if (normalized) { return getChatChannelMeta(normalized).preferOver ?? []; } - const catalogEntry = getChannelPluginCatalogEntry(pluginId, { env }); - return catalogEntry?.meta.preferOver ?? []; + const installedChannelMeta = registry.plugins.find( + (record) => record.id === pluginId, + )?.channelCatalogMeta; + if (installedChannelMeta?.preferOver?.length) { + return installedChannelMeta.preferOver; + } + return resolveExternalCatalogPreferOver(pluginId, env); } function shouldSkipPreferredPluginAutoEnable( @@ -424,6 +354,7 @@ function shouldSkipPreferredPluginAutoEnable( entry: PluginEnableChange, configured: PluginEnableChange[], env: NodeJS.ProcessEnv, + registry: PluginManifestRegistry, ): boolean { for (const other of configured) { if (other.pluginId === entry.pluginId) { @@ -435,7 +366,7 @@ function shouldSkipPreferredPluginAutoEnable( if (isPluginExplicitlyDisabled(cfg, other.pluginId)) { continue; } - const preferOver = resolvePreferredOverIds(other.pluginId, env); + const preferOver = resolvePreferredOverIds(other.pluginId, env, registry); if (preferOver.includes(entry.pluginId)) { return true; } @@ -523,7 +454,7 @@ export function applyPluginAutoEnable(params: { if (isPluginExplicitlyDisabled(next, entry.pluginId)) { continue; } - if (shouldSkipPreferredPluginAutoEnable(next, entry, configured, env)) { + if (shouldSkipPreferredPluginAutoEnable(next, entry, configured, env, registry)) { continue; } const allow = next.plugins?.allow; diff --git a/src/hooks/bundled/session-memory/handler.test.ts b/src/hooks/bundled/session-memory/handler.test.ts index fb7e9ca0a4d..591f6ef7be0 100644 --- a/src/hooks/bundled/session-memory/handler.test.ts +++ b/src/hooks/bundled/session-memory/handler.test.ts @@ -4,7 +4,6 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../config/config.js"; import { writeWorkspaceFile } from "../../../test-helpers/workspace.js"; -import type { HookHandler } from "../../hooks.js"; import { createHookEvent } from "../../hooks.js"; // Avoid calling the embedded Pi agent (global command lane); keep this unit test deterministic. @@ -12,7 +11,7 @@ vi.mock("../../llm-slug-generator.js", () => ({ generateSlugViaLLM: vi.fn().mockResolvedValue("simple-math"), })); -let handler: HookHandler; +let handler: typeof import("./handler.js").default; let suiteWorkspaceRoot = ""; let workspaceCaseCounter = 0; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 5b1716f6a85..2051fc2111c 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1,52 +1,20 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, describe, expect, it } from "vitest"; import { emitDiagnosticEvent, resetDiagnosticEventsForTest } from "../infra/diagnostic-events.js"; +import { buildMemoryPromptSection, registerMemoryPromptSection } from "../memory/prompt-section.js"; import { withEnv } from "../test-utils/env.js"; import { clearPluginCommands, getPluginCommandSpecs } from "./commands.js"; - -async function importFreshPluginTestModules() { - vi.resetModules(); - vi.doUnmock("node:fs"); - vi.doUnmock("node:fs/promises"); - vi.doUnmock("node:module"); - vi.doUnmock("./hook-runner-global.js"); - vi.doUnmock("./hooks.js"); - vi.doUnmock("./loader.js"); - vi.doUnmock("jiti"); - const [loader, hookRunnerGlobal, hooks, runtime, registry, promptSection] = await Promise.all([ - import("./loader.js"), - import("./hook-runner-global.js"), - import("./hooks.js"), - import("./runtime.js"), - import("./registry.js"), - import("../memory/prompt-section.js"), - ]); - return { - ...loader, - ...hookRunnerGlobal, - ...hooks, - ...runtime, - ...registry, - ...promptSection, - }; -} - -const { - __testing, - buildMemoryPromptSection, - clearPluginLoaderCache, - createHookRunner, - createEmptyPluginRegistry, +import { getGlobalHookRunner, resetGlobalHookRunner } from "./hook-runner-global.js"; +import { createHookRunner } from "./hooks.js"; +import { __testing, clearPluginLoaderCache, loadOpenClawPlugins } from "./loader.js"; +import { createEmptyPluginRegistry } from "./registry.js"; +import { getActivePluginRegistry, getActivePluginRegistryKey, - getGlobalHookRunner, - loadOpenClawPlugins, - registerMemoryPromptSection, - resetGlobalHookRunner, setActivePluginRegistry, -} = await importFreshPluginTestModules(); +} from "./runtime.js"; type TempPlugin = { dir: string; file: string; id: string }; type PluginLoadConfig = NonNullable[0]>["config"]; diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 632d4442a9b..30d3bab0e6c 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -2,8 +2,8 @@ import fs from "node:fs"; import path from "node:path"; import { createJiti } from "jiti"; import type { ChannelPlugin } from "../channels/plugins/types.js"; +import { isChannelConfigured } from "../config/channel-configured.js"; import type { OpenClawConfig } from "../config/config.js"; -import { isChannelConfigured } from "../config/plugin-auto-enable.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 383e6ad47cf..febac61201c 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -57,6 +57,10 @@ export type PluginManifestRecord = { schemaCacheKey?: string; configSchema?: Record; configUiHints?: Record; + channelCatalogMeta?: { + id: string; + preferOver?: string[]; + }; }; export type PluginManifestRegistry = { @@ -178,6 +182,16 @@ function buildRecord(params: { schemaCacheKey: params.schemaCacheKey, configSchema: params.configSchema, configUiHints: params.manifest.uiHints, + ...(params.candidate.packageManifest?.channel?.id + ? { + channelCatalogMeta: { + id: params.candidate.packageManifest.channel.id, + ...(params.candidate.packageManifest.channel.preferOver + ? { preferOver: params.candidate.packageManifest.channel.preferOver } + : {}), + }, + } + : {}), }; }