mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-07 15:21:06 +00:00
541 lines
18 KiB
TypeScript
541 lines
18 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import { resolveUserPath } from "../utils.js";
|
|
import { resolveCompatibilityHostVersion } from "../version.js";
|
|
import { loadBundleManifest } from "./bundle-manifest.js";
|
|
import {
|
|
normalizePluginsConfigWithResolver,
|
|
type NormalizedPluginsConfig,
|
|
} from "./config-policy.js";
|
|
import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js";
|
|
import {
|
|
loadPluginManifest,
|
|
type OpenClawPackageManifest,
|
|
type PluginManifest,
|
|
type PluginManifestChannelConfig,
|
|
type PluginManifestContracts,
|
|
} from "./manifest.js";
|
|
import { checkMinHostVersion } from "./min-host-version.js";
|
|
import { isPathInside, safeRealpathSync } from "./path-safety.js";
|
|
import { resolvePluginCacheInputs } from "./roots.js";
|
|
import type {
|
|
PluginBundleFormat,
|
|
PluginConfigUiHint,
|
|
PluginDiagnostic,
|
|
PluginFormat,
|
|
PluginKind,
|
|
PluginOrigin,
|
|
} from "./types.js";
|
|
|
|
type SeenIdEntry = {
|
|
candidate: PluginCandidate;
|
|
recordIndex: number;
|
|
};
|
|
|
|
// Canonicalize identical physical plugin roots with the most explicit source.
|
|
// This only applies when multiple candidates resolve to the same on-disk plugin.
|
|
const PLUGIN_ORIGIN_RANK: Readonly<Record<PluginOrigin, number>> = {
|
|
config: 0,
|
|
workspace: 1,
|
|
global: 2,
|
|
bundled: 3,
|
|
};
|
|
|
|
export type PluginManifestRecord = {
|
|
id: string;
|
|
name?: string;
|
|
description?: string;
|
|
version?: string;
|
|
enabledByDefault?: boolean;
|
|
autoEnableWhenConfiguredProviders?: string[];
|
|
legacyPluginIds?: string[];
|
|
format?: PluginFormat;
|
|
bundleFormat?: PluginBundleFormat;
|
|
bundleCapabilities?: string[];
|
|
kind?: PluginKind | PluginKind[];
|
|
channels: string[];
|
|
providers: string[];
|
|
cliBackends: string[];
|
|
providerAuthEnvVars?: Record<string, string[]>;
|
|
providerAuthChoices?: PluginManifest["providerAuthChoices"];
|
|
skills: string[];
|
|
settingsFiles?: string[];
|
|
hooks: string[];
|
|
origin: PluginOrigin;
|
|
workspaceDir?: string;
|
|
rootDir: string;
|
|
source: string;
|
|
setupSource?: string;
|
|
startupDeferConfiguredChannelFullLoadUntilAfterListen?: boolean;
|
|
manifestPath: string;
|
|
schemaCacheKey?: string;
|
|
configSchema?: Record<string, unknown>;
|
|
configUiHints?: Record<string, PluginConfigUiHint>;
|
|
contracts?: PluginManifestContracts;
|
|
channelConfigs?: Record<string, PluginManifestChannelConfig>;
|
|
channelCatalogMeta?: {
|
|
id: string;
|
|
label?: string;
|
|
blurb?: string;
|
|
preferOver?: readonly string[];
|
|
};
|
|
};
|
|
|
|
export type PluginManifestRegistry = {
|
|
plugins: PluginManifestRecord[];
|
|
diagnostics: PluginDiagnostic[];
|
|
};
|
|
|
|
const registryCache = new Map<string, { expiresAt: number; registry: PluginManifestRegistry }>();
|
|
|
|
// Keep a short cache window to collapse bursty reloads during startup flows.
|
|
const DEFAULT_MANIFEST_CACHE_MS = 1000;
|
|
|
|
export function clearPluginManifestRegistryCache(): void {
|
|
registryCache.clear();
|
|
}
|
|
|
|
function resolveManifestCacheMs(env: NodeJS.ProcessEnv): number {
|
|
const raw = env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS?.trim();
|
|
if (raw === "" || raw === "0") {
|
|
return 0;
|
|
}
|
|
if (!raw) {
|
|
return DEFAULT_MANIFEST_CACHE_MS;
|
|
}
|
|
const parsed = Number.parseInt(raw, 10);
|
|
if (!Number.isFinite(parsed)) {
|
|
return DEFAULT_MANIFEST_CACHE_MS;
|
|
}
|
|
return Math.max(0, parsed);
|
|
}
|
|
|
|
function shouldUseManifestCache(env: NodeJS.ProcessEnv): boolean {
|
|
const disabled = env.OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE?.trim();
|
|
if (disabled) {
|
|
return false;
|
|
}
|
|
return resolveManifestCacheMs(env) > 0;
|
|
}
|
|
|
|
function buildCacheKey(params: {
|
|
workspaceDir?: string;
|
|
plugins: NormalizedPluginsConfig;
|
|
env: NodeJS.ProcessEnv;
|
|
}): string {
|
|
const { roots, loadPaths } = resolvePluginCacheInputs({
|
|
workspaceDir: params.workspaceDir,
|
|
loadPaths: params.plugins.loadPaths,
|
|
env: params.env,
|
|
});
|
|
const workspaceKey = roots.workspace ?? "";
|
|
const configExtensionsRoot = roots.global;
|
|
const bundledRoot = roots.stock ?? "";
|
|
const runtimeServiceVersion = resolveCompatibilityHostVersion(params.env);
|
|
// The manifest registry only depends on where plugins are discovered from (workspace + load paths).
|
|
// It does not depend on allow/deny/entries enable-state, so exclude those for higher cache hit rates.
|
|
return `${workspaceKey}::${configExtensionsRoot}::${bundledRoot}::${runtimeServiceVersion}::${JSON.stringify(loadPaths)}`;
|
|
}
|
|
|
|
function safeStatMtimeMs(filePath: string): number | null {
|
|
try {
|
|
return fs.statSync(filePath).mtimeMs;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizeManifestLabel(raw: string | undefined): string | undefined {
|
|
const trimmed = raw?.trim();
|
|
return trimmed ? trimmed : undefined;
|
|
}
|
|
|
|
function normalizePreferredPluginIds(raw: unknown): string[] | undefined {
|
|
if (!Array.isArray(raw)) {
|
|
return undefined;
|
|
}
|
|
const values = raw
|
|
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
|
|
.filter(Boolean);
|
|
return values.length > 0 ? values : undefined;
|
|
}
|
|
|
|
function mergePackageChannelMetaIntoChannelConfigs(params: {
|
|
channelConfigs?: Record<string, PluginManifestChannelConfig>;
|
|
packageChannel?: OpenClawPackageManifest["channel"];
|
|
}): Record<string, PluginManifestChannelConfig> | undefined {
|
|
const channelId = params.packageChannel?.id?.trim();
|
|
if (!channelId || !params.channelConfigs?.[channelId]) {
|
|
return params.channelConfigs;
|
|
}
|
|
|
|
const existing = params.channelConfigs[channelId];
|
|
const label =
|
|
existing.label ??
|
|
(typeof params.packageChannel?.label === "string" ? params.packageChannel.label.trim() : "");
|
|
const description =
|
|
existing.description ??
|
|
(typeof params.packageChannel?.blurb === "string" ? params.packageChannel.blurb.trim() : "");
|
|
const preferOver =
|
|
existing.preferOver ?? normalizePreferredPluginIds(params.packageChannel?.preferOver);
|
|
|
|
return {
|
|
...params.channelConfigs,
|
|
[channelId]: {
|
|
...existing,
|
|
...(label ? { label } : {}),
|
|
...(description ? { description } : {}),
|
|
...(preferOver?.length ? { preferOver } : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildRecord(params: {
|
|
manifest: PluginManifest;
|
|
candidate: PluginCandidate;
|
|
manifestPath: string;
|
|
schemaCacheKey?: string;
|
|
configSchema?: Record<string, unknown>;
|
|
}): PluginManifestRecord {
|
|
const channelConfigs = mergePackageChannelMetaIntoChannelConfigs({
|
|
channelConfigs: params.manifest.channelConfigs,
|
|
packageChannel: params.candidate.packageManifest?.channel,
|
|
});
|
|
return {
|
|
id: params.manifest.id,
|
|
name: normalizeManifestLabel(params.manifest.name) ?? params.candidate.packageName,
|
|
description:
|
|
normalizeManifestLabel(params.manifest.description) ?? params.candidate.packageDescription,
|
|
version: normalizeManifestLabel(params.manifest.version) ?? params.candidate.packageVersion,
|
|
enabledByDefault: params.manifest.enabledByDefault === true ? true : undefined,
|
|
autoEnableWhenConfiguredProviders: params.manifest.autoEnableWhenConfiguredProviders,
|
|
legacyPluginIds: params.manifest.legacyPluginIds,
|
|
format: params.candidate.format ?? "openclaw",
|
|
bundleFormat: params.candidate.bundleFormat,
|
|
kind: params.manifest.kind,
|
|
channels: params.manifest.channels ?? [],
|
|
providers: params.manifest.providers ?? [],
|
|
cliBackends: params.manifest.cliBackends ?? [],
|
|
providerAuthEnvVars: params.manifest.providerAuthEnvVars,
|
|
providerAuthChoices: params.manifest.providerAuthChoices,
|
|
skills: params.manifest.skills ?? [],
|
|
settingsFiles: [],
|
|
hooks: [],
|
|
origin: params.candidate.origin,
|
|
workspaceDir: params.candidate.workspaceDir,
|
|
rootDir: params.candidate.rootDir,
|
|
source: params.candidate.source,
|
|
setupSource: params.candidate.setupSource,
|
|
startupDeferConfiguredChannelFullLoadUntilAfterListen:
|
|
params.candidate.packageManifest?.startup?.deferConfiguredChannelFullLoadUntilAfterListen ===
|
|
true,
|
|
manifestPath: params.manifestPath,
|
|
schemaCacheKey: params.schemaCacheKey,
|
|
configSchema: params.configSchema,
|
|
configUiHints: params.manifest.uiHints,
|
|
contracts: params.manifest.contracts,
|
|
channelConfigs,
|
|
...(params.candidate.packageManifest?.channel?.id
|
|
? {
|
|
channelCatalogMeta: {
|
|
id: params.candidate.packageManifest.channel.id,
|
|
...(typeof params.candidate.packageManifest.channel.label === "string"
|
|
? { label: params.candidate.packageManifest.channel.label }
|
|
: {}),
|
|
...(typeof params.candidate.packageManifest.channel.blurb === "string"
|
|
? { blurb: params.candidate.packageManifest.channel.blurb }
|
|
: {}),
|
|
...(params.candidate.packageManifest.channel.preferOver
|
|
? { preferOver: params.candidate.packageManifest.channel.preferOver }
|
|
: {}),
|
|
},
|
|
}
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
function buildBundleRecord(params: {
|
|
manifest: {
|
|
id: string;
|
|
name?: string;
|
|
description?: string;
|
|
version?: string;
|
|
skills: string[];
|
|
settingsFiles?: string[];
|
|
hooks: string[];
|
|
capabilities: string[];
|
|
};
|
|
candidate: PluginCandidate;
|
|
manifestPath: string;
|
|
}): PluginManifestRecord {
|
|
return {
|
|
id: params.manifest.id,
|
|
name: normalizeManifestLabel(params.manifest.name) ?? params.candidate.idHint,
|
|
description: normalizeManifestLabel(params.manifest.description),
|
|
version: normalizeManifestLabel(params.manifest.version),
|
|
format: "bundle",
|
|
bundleFormat: params.candidate.bundleFormat,
|
|
bundleCapabilities: params.manifest.capabilities,
|
|
channels: [],
|
|
providers: [],
|
|
cliBackends: [],
|
|
skills: params.manifest.skills ?? [],
|
|
settingsFiles: params.manifest.settingsFiles ?? [],
|
|
hooks: params.manifest.hooks ?? [],
|
|
origin: params.candidate.origin,
|
|
workspaceDir: params.candidate.workspaceDir,
|
|
rootDir: params.candidate.rootDir,
|
|
source: params.candidate.source,
|
|
manifestPath: params.manifestPath,
|
|
schemaCacheKey: undefined,
|
|
configSchema: undefined,
|
|
configUiHints: undefined,
|
|
channelConfigs: undefined,
|
|
};
|
|
}
|
|
|
|
function matchesInstalledPluginRecord(params: {
|
|
pluginId: string;
|
|
candidate: PluginCandidate;
|
|
config?: OpenClawConfig;
|
|
env: NodeJS.ProcessEnv;
|
|
}): boolean {
|
|
if (params.candidate.origin !== "global") {
|
|
return false;
|
|
}
|
|
const record = params.config?.plugins?.installs?.[params.pluginId];
|
|
if (!record) {
|
|
return false;
|
|
}
|
|
const candidateSource = resolveUserPath(params.candidate.source, params.env);
|
|
const trackedPaths = [record.installPath, record.sourcePath]
|
|
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
|
.map((entry) => resolveUserPath(entry, params.env));
|
|
if (trackedPaths.length === 0) {
|
|
return false;
|
|
}
|
|
return trackedPaths.some((trackedPath) => {
|
|
return candidateSource === trackedPath || isPathInside(trackedPath, candidateSource);
|
|
});
|
|
}
|
|
|
|
function resolveDuplicatePrecedenceRank(params: {
|
|
pluginId: string;
|
|
candidate: PluginCandidate;
|
|
config?: OpenClawConfig;
|
|
env: NodeJS.ProcessEnv;
|
|
}): number {
|
|
if (params.candidate.origin === "config") {
|
|
return 0;
|
|
}
|
|
if (
|
|
params.candidate.origin === "global" &&
|
|
matchesInstalledPluginRecord({
|
|
pluginId: params.pluginId,
|
|
candidate: params.candidate,
|
|
config: params.config,
|
|
env: params.env,
|
|
})
|
|
) {
|
|
return 1;
|
|
}
|
|
if (params.candidate.origin === "bundled") {
|
|
// Bundled plugin ids are reserved unless the operator explicitly overrides them.
|
|
return 2;
|
|
}
|
|
if (params.candidate.origin === "workspace") {
|
|
return 3;
|
|
}
|
|
return 4;
|
|
}
|
|
|
|
export function loadPluginManifestRegistry(
|
|
params: {
|
|
config?: OpenClawConfig;
|
|
workspaceDir?: string;
|
|
cache?: boolean;
|
|
env?: NodeJS.ProcessEnv;
|
|
candidates?: PluginCandidate[];
|
|
diagnostics?: PluginDiagnostic[];
|
|
} = {},
|
|
): PluginManifestRegistry {
|
|
const config = params.config ?? {};
|
|
const normalized = normalizePluginsConfigWithResolver(config.plugins);
|
|
const env = params.env ?? process.env;
|
|
const cacheKey = buildCacheKey({ workspaceDir: params.workspaceDir, plugins: normalized, env });
|
|
const cacheEnabled = params.cache !== false && shouldUseManifestCache(env);
|
|
if (cacheEnabled) {
|
|
const cached = registryCache.get(cacheKey);
|
|
if (cached && cached.expiresAt > Date.now()) {
|
|
return cached.registry;
|
|
}
|
|
}
|
|
|
|
const discovery = params.candidates
|
|
? {
|
|
candidates: params.candidates,
|
|
diagnostics: params.diagnostics ?? [],
|
|
}
|
|
: discoverOpenClawPlugins({
|
|
workspaceDir: params.workspaceDir,
|
|
extraPaths: normalized.loadPaths,
|
|
cache: params.cache,
|
|
env,
|
|
});
|
|
const diagnostics: PluginDiagnostic[] = [...discovery.diagnostics];
|
|
const candidates: PluginCandidate[] = discovery.candidates;
|
|
const records: PluginManifestRecord[] = [];
|
|
const seenIds = new Map<string, SeenIdEntry>();
|
|
const realpathCache = new Map<string, string>();
|
|
const currentHostVersion = resolveCompatibilityHostVersion(env);
|
|
|
|
for (const candidate of candidates) {
|
|
const rejectHardlinks = candidate.origin !== "bundled";
|
|
const isBundleRecord = (candidate.format ?? "openclaw") === "bundle";
|
|
const manifestRes:
|
|
| ReturnType<typeof loadPluginManifest>
|
|
| ReturnType<typeof loadBundleManifest>
|
|
| { ok: true; manifest: PluginManifest; manifestPath: string } =
|
|
candidate.origin === "bundled" && candidate.bundledManifest && candidate.bundledManifestPath
|
|
? {
|
|
ok: true,
|
|
manifest: candidate.bundledManifest,
|
|
manifestPath: candidate.bundledManifestPath,
|
|
}
|
|
: isBundleRecord && candidate.bundleFormat
|
|
? loadBundleManifest({
|
|
rootDir: candidate.rootDir,
|
|
bundleFormat: candidate.bundleFormat,
|
|
rejectHardlinks,
|
|
})
|
|
: loadPluginManifest(candidate.rootDir, rejectHardlinks);
|
|
if (!manifestRes.ok) {
|
|
diagnostics.push({
|
|
level: "error",
|
|
message: manifestRes.error,
|
|
source: manifestRes.manifestPath,
|
|
});
|
|
continue;
|
|
}
|
|
const manifest = manifestRes.manifest;
|
|
const minHostVersionCheck = checkMinHostVersion({
|
|
currentVersion: currentHostVersion,
|
|
minHostVersion: candidate.packageManifest?.install?.minHostVersion,
|
|
});
|
|
if (!minHostVersionCheck.ok) {
|
|
const packageManifestSource = path.join(
|
|
candidate.packageDir ?? candidate.rootDir,
|
|
"package.json",
|
|
);
|
|
diagnostics.push({
|
|
level: minHostVersionCheck.kind === "unknown_host_version" ? "warn" : "error",
|
|
pluginId: manifest.id,
|
|
source: packageManifestSource,
|
|
message:
|
|
minHostVersionCheck.kind === "invalid"
|
|
? `plugin manifest invalid | ${minHostVersionCheck.error}`
|
|
: minHostVersionCheck.kind === "unknown_host_version"
|
|
? `plugin requires OpenClaw >=${minHostVersionCheck.requirement.minimumLabel}, but this host version could not be determined; skipping load`
|
|
: `plugin requires OpenClaw >=${minHostVersionCheck.requirement.minimumLabel}, but this host is ${minHostVersionCheck.currentVersion}; skipping load`,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const configSchema = "configSchema" in manifest ? manifest.configSchema : undefined;
|
|
const schemaCacheKey = (() => {
|
|
if (!configSchema) {
|
|
return undefined;
|
|
}
|
|
const manifestMtime = safeStatMtimeMs(manifestRes.manifestPath);
|
|
return manifestMtime
|
|
? `${manifestRes.manifestPath}:${manifestMtime}`
|
|
: manifestRes.manifestPath;
|
|
})();
|
|
|
|
const existing = seenIds.get(manifest.id);
|
|
if (existing) {
|
|
// Check whether both candidates point to the same physical directory
|
|
// (e.g. via symlinks or different path representations). If so, this
|
|
// is a false-positive duplicate and can be silently skipped.
|
|
const samePath = existing.candidate.rootDir === candidate.rootDir;
|
|
const samePlugin = (() => {
|
|
if (samePath) {
|
|
return true;
|
|
}
|
|
const existingReal = safeRealpathSync(existing.candidate.rootDir, realpathCache);
|
|
const candidateReal = safeRealpathSync(candidate.rootDir, realpathCache);
|
|
return Boolean(existingReal && candidateReal && existingReal === candidateReal);
|
|
})();
|
|
if (samePlugin) {
|
|
// Prefer higher-precedence origins even if candidates are passed in
|
|
// an unexpected order (config > workspace > global > bundled).
|
|
if (PLUGIN_ORIGIN_RANK[candidate.origin] < PLUGIN_ORIGIN_RANK[existing.candidate.origin]) {
|
|
records[existing.recordIndex] = isBundleRecord
|
|
? buildBundleRecord({
|
|
manifest: manifest as Parameters<typeof buildBundleRecord>[0]["manifest"],
|
|
candidate,
|
|
manifestPath: manifestRes.manifestPath,
|
|
})
|
|
: buildRecord({
|
|
manifest: manifest as PluginManifest,
|
|
candidate,
|
|
manifestPath: manifestRes.manifestPath,
|
|
schemaCacheKey,
|
|
configSchema,
|
|
});
|
|
seenIds.set(manifest.id, { candidate, recordIndex: existing.recordIndex });
|
|
}
|
|
continue;
|
|
}
|
|
diagnostics.push({
|
|
level: "warn",
|
|
pluginId: manifest.id,
|
|
source: candidate.source,
|
|
message:
|
|
resolveDuplicatePrecedenceRank({
|
|
pluginId: manifest.id,
|
|
candidate,
|
|
config,
|
|
env,
|
|
}) <
|
|
resolveDuplicatePrecedenceRank({
|
|
pluginId: manifest.id,
|
|
candidate: existing.candidate,
|
|
config,
|
|
env,
|
|
})
|
|
? `duplicate plugin id detected; ${existing.candidate.origin} plugin will be overridden by ${candidate.origin} plugin (${candidate.source})`
|
|
: `duplicate plugin id detected; ${candidate.origin} plugin will be overridden by ${existing.candidate.origin} plugin (${candidate.source})`,
|
|
});
|
|
} else {
|
|
seenIds.set(manifest.id, { candidate, recordIndex: records.length });
|
|
}
|
|
|
|
records.push(
|
|
isBundleRecord
|
|
? buildBundleRecord({
|
|
manifest: manifest as Parameters<typeof buildBundleRecord>[0]["manifest"],
|
|
candidate,
|
|
manifestPath: manifestRes.manifestPath,
|
|
})
|
|
: buildRecord({
|
|
manifest: manifest as PluginManifest,
|
|
candidate,
|
|
manifestPath: manifestRes.manifestPath,
|
|
schemaCacheKey,
|
|
configSchema,
|
|
}),
|
|
);
|
|
}
|
|
|
|
const registry = { plugins: records, diagnostics };
|
|
if (cacheEnabled) {
|
|
const ttl = resolveManifestCacheMs(env);
|
|
if (ttl > 0) {
|
|
registryCache.set(cacheKey, { expiresAt: Date.now() + ttl, registry });
|
|
}
|
|
}
|
|
return registry;
|
|
}
|