mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-23 07:51:33 +00:00
test: trim import-heavy startup paths
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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";
|
||||
|
||||
159
src/config/channel-configured.ts
Normal file
159
src/config/channel-configured.ts
Normal file
@@ -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<string, unknown> | null {
|
||||
const channels = cfg.channels as Record<string, unknown> | 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<string, StructuredChannelConfigSpec> = {
|
||||
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<string, unknown>, 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);
|
||||
}
|
||||
@@ -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<string, unknown> | null {
|
||||
const channels = cfg.channels as Record<string, unknown> | 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<string, StructuredChannelConfigSpec> = {
|
||||
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<string, unknown>, 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<string
|
||||
return map;
|
||||
}
|
||||
|
||||
type ExternalCatalogChannelEntry = {
|
||||
id: string;
|
||||
preferOver: string[];
|
||||
};
|
||||
|
||||
function splitEnvPaths(value: string): string[] {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
return trimmed
|
||||
.split(/[;,]/g)
|
||||
.flatMap((chunk) => 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<string, string>,
|
||||
@@ -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<string>(listKnownChannelPluginIds(env));
|
||||
function collectCandidateChannelIds(cfg: OpenClawConfig): string[] {
|
||||
const channelIds = new Set<string>(listKnownChannelPluginIds());
|
||||
const configuredChannels = cfg.channels as Record<string, unknown> | 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<Parameters<typeof loadOpenClawPlugins>[0]>["config"];
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -57,6 +57,10 @@ export type PluginManifestRecord = {
|
||||
schemaCacheKey?: string;
|
||||
configSchema?: Record<string, unknown>;
|
||||
configUiHints?: Record<string, PluginConfigUiHint>;
|
||||
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 }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user