mirror of https://github.com/openclaw/openclaw.git
514 lines
16 KiB
TypeScript
514 lines
16 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { createJiti } from "jiti";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
|
import { activateExtensionHostRegistry } from "../extension-host/activation.js";
|
|
import {
|
|
listPluginSdkAliasCandidates,
|
|
listPluginSdkExportedSubpaths,
|
|
resolvePluginSdkAlias,
|
|
resolvePluginSdkAliasCandidateOrder,
|
|
resolvePluginSdkAliasFile,
|
|
resolvePluginSdkScopedAliasMap,
|
|
} from "../extension-host/loader-compat.js";
|
|
import {
|
|
buildExtensionHostProvenanceIndex,
|
|
compareExtensionHostDuplicateCandidateOrder,
|
|
pushExtensionHostDiagnostics,
|
|
recordExtensionHostPluginError,
|
|
warnAboutUntrackedLoadedExtensions,
|
|
warnWhenExtensionAllowlistIsOpen,
|
|
} from "../extension-host/loader-policy.js";
|
|
import { prepareExtensionHostPluginCandidate } from "../extension-host/loader-records.js";
|
|
import {
|
|
planExtensionHostLoadedPlugin,
|
|
runExtensionHostPluginRegister,
|
|
} from "../extension-host/loader-register.js";
|
|
import {
|
|
resolveExtensionHostEarlyMemoryDecision,
|
|
resolveExtensionHostModuleExport,
|
|
} from "../extension-host/loader-runtime.js";
|
|
import {
|
|
appendExtensionHostPluginRecord,
|
|
setExtensionHostPluginRecordDisabled,
|
|
setExtensionHostPluginRecordError,
|
|
} from "../extension-host/loader-state.js";
|
|
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
|
|
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
|
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
|
import { resolveUserPath } from "../utils.js";
|
|
import { clearPluginCommands } from "./commands.js";
|
|
import {
|
|
applyTestPluginDefaults,
|
|
normalizePluginsConfig,
|
|
type NormalizedPluginsConfig,
|
|
} from "./config-state.js";
|
|
import { discoverOpenClawPlugins } from "./discovery.js";
|
|
import { loadPluginManifestRegistry } from "./manifest-registry.js";
|
|
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
|
|
import { resolvePluginCacheInputs } from "./roots.js";
|
|
import { createPluginRuntime, type CreatePluginRuntimeOptions } from "./runtime/index.js";
|
|
import type { PluginRuntime } from "./runtime/types.js";
|
|
import type { OpenClawPluginModule, PluginLogger } from "./types.js";
|
|
|
|
export type PluginLoadResult = PluginRegistry;
|
|
|
|
export type PluginLoadOptions = {
|
|
config?: OpenClawConfig;
|
|
workspaceDir?: string;
|
|
// Allows callers to resolve plugin roots and load paths against an explicit env
|
|
// instead of the process-global environment.
|
|
env?: NodeJS.ProcessEnv;
|
|
logger?: PluginLogger;
|
|
coreGatewayHandlers?: Record<string, GatewayRequestHandler>;
|
|
runtimeOptions?: CreatePluginRuntimeOptions;
|
|
cache?: boolean;
|
|
mode?: "full" | "validate";
|
|
};
|
|
|
|
const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 32;
|
|
const registryCache = new Map<string, PluginRegistry>();
|
|
const openAllowlistWarningCache = new Set<string>();
|
|
|
|
export function clearPluginLoaderCache(): void {
|
|
registryCache.clear();
|
|
openAllowlistWarningCache.clear();
|
|
}
|
|
|
|
const defaultLogger = () => createSubsystemLogger("plugins");
|
|
|
|
export const __testing = {
|
|
listPluginSdkAliasCandidates,
|
|
listPluginSdkExportedSubpaths,
|
|
resolvePluginSdkAliasCandidateOrder,
|
|
resolvePluginSdkAliasFile,
|
|
maxPluginRegistryCacheEntries: MAX_PLUGIN_REGISTRY_CACHE_ENTRIES,
|
|
};
|
|
|
|
function getCachedPluginRegistry(cacheKey: string): PluginRegistry | undefined {
|
|
const cached = registryCache.get(cacheKey);
|
|
if (!cached) {
|
|
return undefined;
|
|
}
|
|
// Refresh insertion order so frequently reused registries survive eviction.
|
|
registryCache.delete(cacheKey);
|
|
registryCache.set(cacheKey, cached);
|
|
return cached;
|
|
}
|
|
|
|
function setCachedPluginRegistry(cacheKey: string, registry: PluginRegistry): void {
|
|
if (registryCache.has(cacheKey)) {
|
|
registryCache.delete(cacheKey);
|
|
}
|
|
registryCache.set(cacheKey, registry);
|
|
while (registryCache.size > MAX_PLUGIN_REGISTRY_CACHE_ENTRIES) {
|
|
const oldestKey = registryCache.keys().next().value;
|
|
if (!oldestKey) {
|
|
break;
|
|
}
|
|
registryCache.delete(oldestKey);
|
|
}
|
|
}
|
|
|
|
function buildCacheKey(params: {
|
|
workspaceDir?: string;
|
|
plugins: NormalizedPluginsConfig;
|
|
installs?: Record<string, PluginInstallRecord>;
|
|
env: NodeJS.ProcessEnv;
|
|
}): string {
|
|
const { roots, loadPaths } = resolvePluginCacheInputs({
|
|
workspaceDir: params.workspaceDir,
|
|
loadPaths: params.plugins.loadPaths,
|
|
env: params.env,
|
|
});
|
|
const installs = Object.fromEntries(
|
|
Object.entries(params.installs ?? {}).map(([pluginId, install]) => [
|
|
pluginId,
|
|
{
|
|
...install,
|
|
installPath:
|
|
typeof install.installPath === "string"
|
|
? resolveUserPath(install.installPath, params.env)
|
|
: install.installPath,
|
|
sourcePath:
|
|
typeof install.sourcePath === "string"
|
|
? resolveUserPath(install.sourcePath, params.env)
|
|
: install.sourcePath,
|
|
},
|
|
]),
|
|
);
|
|
return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({
|
|
...params.plugins,
|
|
installs,
|
|
loadPaths,
|
|
})}`;
|
|
}
|
|
|
|
export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegistry {
|
|
const env = options.env ?? process.env;
|
|
// Test env: default-disable plugins unless explicitly configured.
|
|
// This keeps unit/gateway suites fast and avoids loading heavyweight plugin deps by accident.
|
|
const cfg = applyTestPluginDefaults(options.config ?? {}, env);
|
|
const logger = options.logger ?? defaultLogger();
|
|
const validateOnly = options.mode === "validate";
|
|
const normalized = normalizePluginsConfig(cfg.plugins);
|
|
const cacheKey = buildCacheKey({
|
|
workspaceDir: options.workspaceDir,
|
|
plugins: normalized,
|
|
installs: cfg.plugins?.installs,
|
|
env,
|
|
});
|
|
const cacheEnabled = options.cache !== false;
|
|
if (cacheEnabled) {
|
|
const cached = getCachedPluginRegistry(cacheKey);
|
|
if (cached) {
|
|
activateExtensionHostRegistry(cached, cacheKey);
|
|
return cached;
|
|
}
|
|
}
|
|
|
|
// Clear previously registered plugin commands before reloading
|
|
clearPluginCommands();
|
|
|
|
// Lazily initialize the runtime so startup paths that discover/skip plugins do
|
|
// not eagerly load every channel runtime dependency.
|
|
let resolvedRuntime: PluginRuntime | null = null;
|
|
const resolveRuntime = (): PluginRuntime => {
|
|
resolvedRuntime ??= createPluginRuntime(options.runtimeOptions);
|
|
return resolvedRuntime;
|
|
};
|
|
const runtime = new Proxy({} as PluginRuntime, {
|
|
get(_target, prop, receiver) {
|
|
return Reflect.get(resolveRuntime(), prop, receiver);
|
|
},
|
|
set(_target, prop, value, receiver) {
|
|
return Reflect.set(resolveRuntime(), prop, value, receiver);
|
|
},
|
|
has(_target, prop) {
|
|
return Reflect.has(resolveRuntime(), prop);
|
|
},
|
|
ownKeys() {
|
|
return Reflect.ownKeys(resolveRuntime() as object);
|
|
},
|
|
getOwnPropertyDescriptor(_target, prop) {
|
|
return Reflect.getOwnPropertyDescriptor(resolveRuntime() as object, prop);
|
|
},
|
|
defineProperty(_target, prop, attributes) {
|
|
return Reflect.defineProperty(resolveRuntime() as object, prop, attributes);
|
|
},
|
|
deleteProperty(_target, prop) {
|
|
return Reflect.deleteProperty(resolveRuntime() as object, prop);
|
|
},
|
|
getPrototypeOf() {
|
|
return Reflect.getPrototypeOf(resolveRuntime() as object);
|
|
},
|
|
});
|
|
const { registry, createApi } = createPluginRegistry({
|
|
logger,
|
|
runtime,
|
|
coreGatewayHandlers: options.coreGatewayHandlers as Record<string, GatewayRequestHandler>,
|
|
});
|
|
|
|
const discovery = discoverOpenClawPlugins({
|
|
workspaceDir: options.workspaceDir,
|
|
extraPaths: normalized.loadPaths,
|
|
cache: options.cache,
|
|
env,
|
|
});
|
|
const manifestRegistry = loadPluginManifestRegistry({
|
|
config: cfg,
|
|
workspaceDir: options.workspaceDir,
|
|
cache: options.cache,
|
|
env,
|
|
candidates: discovery.candidates,
|
|
diagnostics: discovery.diagnostics,
|
|
});
|
|
pushExtensionHostDiagnostics(registry.diagnostics, manifestRegistry.diagnostics);
|
|
warnWhenExtensionAllowlistIsOpen({
|
|
logger,
|
|
pluginsEnabled: normalized.enabled,
|
|
allow: normalized.allow,
|
|
warningCacheKey: cacheKey,
|
|
warningCache: openAllowlistWarningCache,
|
|
discoverablePlugins: manifestRegistry.plugins.map((plugin) => ({
|
|
id: plugin.id,
|
|
source: plugin.source,
|
|
origin: plugin.origin,
|
|
})),
|
|
});
|
|
const provenance = buildExtensionHostProvenanceIndex({
|
|
config: cfg,
|
|
normalizedLoadPaths: normalized.loadPaths,
|
|
env,
|
|
});
|
|
|
|
// Lazy: avoid creating the Jiti loader when all plugins are disabled (common in unit tests).
|
|
let jitiLoader: ReturnType<typeof createJiti> | null = null;
|
|
const getJiti = () => {
|
|
if (jitiLoader) {
|
|
return jitiLoader;
|
|
}
|
|
const pluginSdkAlias = resolvePluginSdkAlias();
|
|
const aliasMap = {
|
|
...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}),
|
|
...resolvePluginSdkScopedAliasMap(),
|
|
};
|
|
jitiLoader = createJiti(import.meta.url, {
|
|
interopDefault: true,
|
|
extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"],
|
|
...(Object.keys(aliasMap).length > 0
|
|
? {
|
|
alias: aliasMap,
|
|
}
|
|
: {}),
|
|
});
|
|
return jitiLoader;
|
|
};
|
|
|
|
const manifestByRoot = new Map(
|
|
manifestRegistry.plugins.map((record) => [record.rootDir, record]),
|
|
);
|
|
const orderedCandidates = [...discovery.candidates].toSorted((left, right) => {
|
|
return compareExtensionHostDuplicateCandidateOrder({
|
|
left,
|
|
right,
|
|
manifestByRoot,
|
|
provenance,
|
|
env,
|
|
});
|
|
});
|
|
|
|
const seenIds = new Map<string, PluginRecord["origin"]>();
|
|
const memorySlot = normalized.slots.memory;
|
|
let selectedMemoryPluginId: string | null = null;
|
|
let memorySlotMatched = false;
|
|
|
|
for (const candidate of orderedCandidates) {
|
|
const manifestRecord = manifestByRoot.get(candidate.rootDir);
|
|
if (!manifestRecord) {
|
|
continue;
|
|
}
|
|
const pluginId = manifestRecord.id;
|
|
const preparedCandidate = prepareExtensionHostPluginCandidate({
|
|
candidate,
|
|
manifestRecord,
|
|
normalizedConfig: normalized,
|
|
rootConfig: cfg,
|
|
seenIds,
|
|
});
|
|
if (preparedCandidate.kind === "duplicate") {
|
|
const { record } = preparedCandidate;
|
|
appendExtensionHostPluginRecord({ registry, record });
|
|
continue;
|
|
}
|
|
const { record, entry, enableState } = preparedCandidate;
|
|
const pushPluginLoadError = (message: string) => {
|
|
setExtensionHostPluginRecordError(record, message);
|
|
appendExtensionHostPluginRecord({
|
|
registry,
|
|
record,
|
|
seenIds,
|
|
pluginId,
|
|
origin: candidate.origin,
|
|
});
|
|
registry.diagnostics.push({
|
|
level: "error",
|
|
pluginId: record.id,
|
|
source: record.source,
|
|
message: record.error,
|
|
});
|
|
};
|
|
|
|
if (!enableState.enabled) {
|
|
setExtensionHostPluginRecordDisabled(record, enableState.reason);
|
|
appendExtensionHostPluginRecord({
|
|
registry,
|
|
record,
|
|
seenIds,
|
|
pluginId,
|
|
origin: candidate.origin,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Fast-path bundled memory plugins that are guaranteed disabled by slot policy.
|
|
// This avoids opening/importing heavy memory plugin modules that will never register.
|
|
const earlyMemoryDecision = resolveExtensionHostEarlyMemoryDecision({
|
|
origin: candidate.origin,
|
|
manifestKind: manifestRecord.kind,
|
|
recordId: record.id,
|
|
memorySlot,
|
|
selectedMemoryPluginId,
|
|
});
|
|
if (!earlyMemoryDecision.enabled) {
|
|
setExtensionHostPluginRecordDisabled(record, earlyMemoryDecision.reason);
|
|
appendExtensionHostPluginRecord({
|
|
registry,
|
|
record,
|
|
seenIds,
|
|
pluginId,
|
|
origin: candidate.origin,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (!manifestRecord.configSchema) {
|
|
pushPluginLoadError("missing config schema");
|
|
continue;
|
|
}
|
|
|
|
const pluginRoot = safeRealpathOrResolve(candidate.rootDir);
|
|
const opened = openBoundaryFileSync({
|
|
absolutePath: candidate.source,
|
|
rootPath: pluginRoot,
|
|
boundaryLabel: "plugin root",
|
|
rejectHardlinks: candidate.origin !== "bundled",
|
|
skipLexicalRootCheck: true,
|
|
});
|
|
if (!opened.ok) {
|
|
pushPluginLoadError("plugin entry path escapes plugin root or fails alias checks");
|
|
continue;
|
|
}
|
|
const safeSource = opened.path;
|
|
fs.closeSync(opened.fd);
|
|
|
|
let mod: OpenClawPluginModule | null = null;
|
|
try {
|
|
mod = getJiti()(safeSource) as OpenClawPluginModule;
|
|
} catch (err) {
|
|
recordExtensionHostPluginError({
|
|
logger,
|
|
registry,
|
|
record,
|
|
seenIds,
|
|
pluginId,
|
|
origin: candidate.origin,
|
|
error: err,
|
|
logPrefix: `[plugins] ${record.id} failed to load from ${record.source}: `,
|
|
diagnosticMessagePrefix: "failed to load plugin: ",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const resolved = resolveExtensionHostModuleExport(mod);
|
|
const definition = resolved.definition;
|
|
const register = resolved.register;
|
|
|
|
const loadedPlan = planExtensionHostLoadedPlugin({
|
|
record,
|
|
manifestRecord,
|
|
definition,
|
|
register,
|
|
diagnostics: registry.diagnostics,
|
|
memorySlot,
|
|
selectedMemoryPluginId,
|
|
entryConfig: entry?.config,
|
|
validateOnly,
|
|
});
|
|
if (loadedPlan.memorySlotMatched) {
|
|
memorySlotMatched = true;
|
|
}
|
|
selectedMemoryPluginId = loadedPlan.selectedMemoryPluginId;
|
|
|
|
if (loadedPlan.kind === "error") {
|
|
pushPluginLoadError(loadedPlan.message);
|
|
continue;
|
|
}
|
|
|
|
if (loadedPlan.kind === "disabled") {
|
|
setExtensionHostPluginRecordDisabled(record, loadedPlan.reason);
|
|
appendExtensionHostPluginRecord({
|
|
registry,
|
|
record,
|
|
seenIds,
|
|
pluginId,
|
|
origin: candidate.origin,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (loadedPlan.kind === "invalid-config") {
|
|
logger.error(`[plugins] ${record.id} ${loadedPlan.message}`);
|
|
pushPluginLoadError(loadedPlan.message);
|
|
continue;
|
|
}
|
|
|
|
if (loadedPlan.kind === "validate-only") {
|
|
appendExtensionHostPluginRecord({
|
|
registry,
|
|
record,
|
|
seenIds,
|
|
pluginId,
|
|
origin: candidate.origin,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (loadedPlan.kind === "missing-register") {
|
|
logger.error(`[plugins] ${record.id} missing register/activate export`);
|
|
pushPluginLoadError(loadedPlan.message);
|
|
continue;
|
|
}
|
|
|
|
const registerResult = runExtensionHostPluginRegister({
|
|
register: loadedPlan.register,
|
|
createApi,
|
|
record,
|
|
config: cfg,
|
|
pluginConfig: loadedPlan.pluginConfig,
|
|
hookPolicy: entry?.hooks,
|
|
diagnostics: registry.diagnostics,
|
|
});
|
|
if (!registerResult.ok) {
|
|
recordExtensionHostPluginError({
|
|
logger,
|
|
registry,
|
|
record,
|
|
seenIds,
|
|
pluginId,
|
|
origin: candidate.origin,
|
|
error: registerResult.error,
|
|
logPrefix: `[plugins] ${record.id} failed during register from ${record.source}: `,
|
|
diagnosticMessagePrefix: "plugin failed during register: ",
|
|
});
|
|
continue;
|
|
}
|
|
appendExtensionHostPluginRecord({
|
|
registry,
|
|
record,
|
|
seenIds,
|
|
pluginId,
|
|
origin: candidate.origin,
|
|
});
|
|
}
|
|
|
|
if (typeof memorySlot === "string" && !memorySlotMatched) {
|
|
registry.diagnostics.push({
|
|
level: "warn",
|
|
message: `memory slot plugin not found or not marked as memory: ${memorySlot}`,
|
|
});
|
|
}
|
|
|
|
warnAboutUntrackedLoadedExtensions({
|
|
registry,
|
|
provenance,
|
|
logger,
|
|
env,
|
|
});
|
|
|
|
if (cacheEnabled) {
|
|
setCachedPluginRegistry(cacheKey, registry);
|
|
}
|
|
activateExtensionHostRegistry(registry, cacheKey);
|
|
return registry;
|
|
}
|
|
|
|
function safeRealpathOrResolve(value: string): string {
|
|
try {
|
|
return fs.realpathSync(value);
|
|
} catch {
|
|
return path.resolve(value);
|
|
}
|
|
}
|