mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 02:40:42 +00:00
3574 lines
125 KiB
TypeScript
3574 lines
125 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import {
|
|
clearAgentHarnesses,
|
|
listRegisteredAgentHarnesses,
|
|
restoreRegisteredAgentHarnesses,
|
|
} from "../agents/harness/registry.js";
|
|
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
|
import { isChannelConfigured } from "../config/channel-configured.js";
|
|
import type { OpenClawConfig } from "../config/types.openclaw.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";
|
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
|
import {
|
|
DEFAULT_MEMORY_DREAMING_PLUGIN_ID,
|
|
resolveMemoryDreamingConfig,
|
|
resolveMemoryDreamingPluginConfig,
|
|
} from "../memory-host-sdk/dreaming.js";
|
|
import {
|
|
normalizeLowercaseStringOrEmpty,
|
|
normalizeOptionalString,
|
|
} from "../shared/string-coerce.js";
|
|
import {
|
|
clearDetachedTaskLifecycleRuntimeRegistration,
|
|
getDetachedTaskLifecycleRuntimeRegistration,
|
|
restoreDetachedTaskLifecycleRuntimeRegistration,
|
|
} from "../tasks/detached-task-runtime-state.js";
|
|
import { resolveUserPath } from "../utils.js";
|
|
import { resolvePluginActivationSourceConfig } from "./activation-source-config.js";
|
|
import { buildPluginApi } from "./api-builder.js";
|
|
import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js";
|
|
import {
|
|
clearBundledRuntimeDependencyNodePaths,
|
|
ensureBundledPluginRuntimeDeps,
|
|
installBundledRuntimeDeps,
|
|
resolveBundledRuntimeDependencyInstallRoot,
|
|
resolveBundledRuntimeDependencyPackageRoot,
|
|
registerBundledRuntimeDependencyNodePath,
|
|
withBundledRuntimeDepsFilesystemLock,
|
|
type BundledRuntimeDepsInstallParams,
|
|
} from "./bundled-runtime-deps.js";
|
|
import {
|
|
clearPluginCommands,
|
|
listRegisteredPluginCommands,
|
|
restorePluginCommands,
|
|
} from "./command-registry-state.js";
|
|
import {
|
|
clearCompactionProviders,
|
|
listRegisteredCompactionProviders,
|
|
restoreRegisteredCompactionProviders,
|
|
} from "./compaction-provider.js";
|
|
import {
|
|
applyTestPluginDefaults,
|
|
createPluginActivationSource,
|
|
normalizePluginsConfig,
|
|
resolveEffectiveEnableState,
|
|
resolveEffectivePluginActivationState,
|
|
resolveMemorySlotDecision,
|
|
type PluginActivationConfigSource,
|
|
type NormalizedPluginsConfig,
|
|
type PluginActivationState,
|
|
} from "./config-state.js";
|
|
import { discoverOpenClawPlugins } from "./discovery.js";
|
|
import { getGlobalHookRunner, initializeGlobalHookRunner } from "./hook-runner-global.js";
|
|
import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-records.js";
|
|
import {
|
|
clearPluginInteractiveHandlers,
|
|
listPluginInteractiveHandlers,
|
|
restorePluginInteractiveHandlers,
|
|
} from "./interactive-registry.js";
|
|
import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js";
|
|
import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js";
|
|
import type { PluginBundleFormat, PluginDiagnostic, PluginFormat } from "./manifest-types.js";
|
|
import type { PluginManifestContracts } from "./manifest.js";
|
|
import {
|
|
clearMemoryEmbeddingProviders,
|
|
listRegisteredMemoryEmbeddingProviders,
|
|
restoreRegisteredMemoryEmbeddingProviders,
|
|
} from "./memory-embedding-providers.js";
|
|
import {
|
|
clearMemoryPluginState,
|
|
getMemoryCapabilityRegistration,
|
|
getMemoryFlushPlanResolver,
|
|
getMemoryPromptSectionBuilder,
|
|
getMemoryRuntime,
|
|
listMemoryCorpusSupplements,
|
|
listMemoryPromptSupplements,
|
|
restoreMemoryPluginState,
|
|
} from "./memory-state.js";
|
|
import { unwrapDefaultModuleExport } from "./module-export.js";
|
|
import { isPathInside, safeStatSync } from "./path-safety.js";
|
|
import { withProfile } from "./plugin-load-profile.js";
|
|
import {
|
|
createPluginIdScopeSet,
|
|
hasExplicitPluginIdScope,
|
|
normalizePluginIdScope,
|
|
serializePluginIdScope,
|
|
} from "./plugin-scope.js";
|
|
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
|
|
import { resolvePluginCacheInputs } from "./roots.js";
|
|
import {
|
|
getActivePluginRegistry,
|
|
getActivePluginRegistryKey,
|
|
getActivePluginRuntimeSubagentMode,
|
|
recordImportedPluginId,
|
|
setActivePluginRegistry,
|
|
} from "./runtime.js";
|
|
import type { CreatePluginRuntimeOptions } from "./runtime/types.js";
|
|
import type { PluginRuntime } from "./runtime/types.js";
|
|
import { validateJsonSchemaValue } from "./schema-validator.js";
|
|
import {
|
|
buildPluginLoaderAliasMap,
|
|
buildPluginLoaderJitiOptions,
|
|
listPluginSdkAliasCandidates,
|
|
listPluginSdkExportedSubpaths,
|
|
type PluginSdkResolutionPreference,
|
|
resolveExtensionApiAlias,
|
|
resolvePluginSdkAliasCandidateOrder,
|
|
resolvePluginSdkAliasFile,
|
|
resolvePluginRuntimeModulePath,
|
|
resolvePluginSdkScopedAliasMap,
|
|
normalizeJitiAliasTargetPath,
|
|
shouldPreferNativeJiti,
|
|
} from "./sdk-alias.js";
|
|
import { hasKind, kindsEqual } from "./slots.js";
|
|
import type {
|
|
OpenClawPluginApi,
|
|
OpenClawPluginDefinition,
|
|
OpenClawPluginModule,
|
|
PluginLogger,
|
|
PluginRegistrationMode,
|
|
} from "./types.js";
|
|
|
|
export type PluginLoadResult = PluginRegistry;
|
|
|
|
export type PluginLoadOptions = {
|
|
config?: OpenClawConfig;
|
|
activationSourceConfig?: OpenClawConfig;
|
|
autoEnabledReasons?: Readonly<Record<string, string[]>>;
|
|
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;
|
|
pluginSdkResolution?: PluginSdkResolutionPreference;
|
|
cache?: boolean;
|
|
mode?: "full" | "validate";
|
|
onlyPluginIds?: string[];
|
|
includeSetupOnlyChannelPlugins?: boolean;
|
|
forceSetupOnlyChannelPlugins?: boolean;
|
|
requireSetupEntryForSetupOnlyChannelPlugins?: boolean;
|
|
/**
|
|
* Prefer `setupEntry` for configured channel plugins that explicitly opt in
|
|
* via package metadata because their setup entry covers the pre-listen startup surface.
|
|
*/
|
|
preferSetupRuntimeForChannelPlugins?: boolean;
|
|
activate?: boolean;
|
|
loadModules?: boolean;
|
|
installBundledRuntimeDeps?: boolean;
|
|
throwOnLoadError?: boolean;
|
|
bundledRuntimeDepsInstaller?: (params: BundledRuntimeDepsInstallParams) => void;
|
|
};
|
|
|
|
const CLI_METADATA_ENTRY_BASENAMES = [
|
|
"cli-metadata.ts",
|
|
"cli-metadata.js",
|
|
"cli-metadata.mjs",
|
|
"cli-metadata.cjs",
|
|
] as const;
|
|
|
|
function resolveDreamingSidecarEngineId(params: {
|
|
cfg: OpenClawConfig;
|
|
memorySlot: string | null | undefined;
|
|
}): string | null {
|
|
const normalizedMemorySlot = normalizeLowercaseStringOrEmpty(params.memorySlot);
|
|
if (
|
|
!normalizedMemorySlot ||
|
|
normalizedMemorySlot === "none" ||
|
|
normalizedMemorySlot === DEFAULT_MEMORY_DREAMING_PLUGIN_ID
|
|
) {
|
|
return null;
|
|
}
|
|
const dreamingConfig = resolveMemoryDreamingConfig({
|
|
pluginConfig: resolveMemoryDreamingPluginConfig(params.cfg),
|
|
cfg: params.cfg,
|
|
});
|
|
return dreamingConfig.enabled ? DEFAULT_MEMORY_DREAMING_PLUGIN_ID : null;
|
|
}
|
|
|
|
export class PluginLoadFailureError extends Error {
|
|
readonly pluginIds: string[];
|
|
readonly registry: PluginRegistry;
|
|
|
|
constructor(registry: PluginRegistry) {
|
|
const failedPlugins = registry.plugins.filter((entry) => entry.status === "error");
|
|
const summary = failedPlugins
|
|
.map((entry) => `${entry.id}: ${entry.error ?? "unknown plugin load error"}`)
|
|
.join("; ");
|
|
super(`plugin load failed: ${summary}`);
|
|
this.name = "PluginLoadFailureError";
|
|
this.pluginIds = failedPlugins.map((entry) => entry.id);
|
|
this.registry = registry;
|
|
}
|
|
}
|
|
|
|
export class PluginLoadReentryError extends Error {
|
|
readonly cacheKey: string;
|
|
|
|
constructor(cacheKey: string) {
|
|
super(`plugin load reentry detected for cache key: ${cacheKey}`);
|
|
this.name = "PluginLoadReentryError";
|
|
this.cacheKey = cacheKey;
|
|
}
|
|
}
|
|
|
|
type CachedPluginState = {
|
|
registry: PluginRegistry;
|
|
detachedTaskRuntimeRegistration: ReturnType<typeof getDetachedTaskLifecycleRuntimeRegistration>;
|
|
commands?: ReturnType<typeof listRegisteredPluginCommands>;
|
|
interactiveHandlers?: ReturnType<typeof listPluginInteractiveHandlers>;
|
|
memoryCapability: ReturnType<typeof getMemoryCapabilityRegistration>;
|
|
memoryCorpusSupplements: ReturnType<typeof listMemoryCorpusSupplements>;
|
|
agentHarnesses: ReturnType<typeof listRegisteredAgentHarnesses>;
|
|
compactionProviders: ReturnType<typeof listRegisteredCompactionProviders>;
|
|
memoryEmbeddingProviders: ReturnType<typeof listRegisteredMemoryEmbeddingProviders>;
|
|
memoryFlushPlanResolver: ReturnType<typeof getMemoryFlushPlanResolver>;
|
|
memoryPromptBuilder: ReturnType<typeof getMemoryPromptSectionBuilder>;
|
|
memoryPromptSupplements: ReturnType<typeof listMemoryPromptSupplements>;
|
|
memoryRuntime: ReturnType<typeof getMemoryRuntime>;
|
|
};
|
|
|
|
const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 128;
|
|
let pluginRegistryCacheEntryCap = MAX_PLUGIN_REGISTRY_CACHE_ENTRIES;
|
|
const registryCache = new Map<string, CachedPluginState>();
|
|
const inFlightPluginRegistryLoads = new Set<string>();
|
|
const openAllowlistWarningCache = new Set<string>();
|
|
const LAZY_RUNTIME_REFLECTION_KEYS = [
|
|
"version",
|
|
"config",
|
|
"agent",
|
|
"subagent",
|
|
"system",
|
|
"media",
|
|
"tts",
|
|
"stt",
|
|
"channel",
|
|
"events",
|
|
"logging",
|
|
"state",
|
|
"modelAuth",
|
|
] as const satisfies readonly (keyof PluginRuntime)[];
|
|
|
|
export function clearPluginLoaderCache(): void {
|
|
registryCache.clear();
|
|
inFlightPluginRegistryLoads.clear();
|
|
openAllowlistWarningCache.clear();
|
|
clearBundledRuntimeDependencyNodePaths();
|
|
bundledRuntimeDependencyJitiAliases.clear();
|
|
clearAgentHarnesses();
|
|
clearPluginCommands();
|
|
clearCompactionProviders();
|
|
clearDetachedTaskLifecycleRuntimeRegistration();
|
|
clearPluginInteractiveHandlers();
|
|
clearMemoryEmbeddingProviders();
|
|
clearMemoryPluginState();
|
|
}
|
|
|
|
const defaultLogger = () => createSubsystemLogger("plugins");
|
|
const BUNDLED_RUNTIME_MIRROR_LOCK_DIR = ".openclaw-runtime-mirror.lock";
|
|
|
|
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
return (
|
|
(typeof value === "object" || typeof value === "function") &&
|
|
value !== null &&
|
|
typeof (value as { then?: unknown }).then === "function"
|
|
);
|
|
}
|
|
|
|
type PluginRegistrySnapshot = {
|
|
arrays: {
|
|
tools: PluginRegistry["tools"];
|
|
hooks: PluginRegistry["hooks"];
|
|
typedHooks: PluginRegistry["typedHooks"];
|
|
channels: PluginRegistry["channels"];
|
|
channelSetups: PluginRegistry["channelSetups"];
|
|
providers: PluginRegistry["providers"];
|
|
cliBackends: NonNullable<PluginRegistry["cliBackends"]>;
|
|
textTransforms: PluginRegistry["textTransforms"];
|
|
speechProviders: PluginRegistry["speechProviders"];
|
|
realtimeTranscriptionProviders: PluginRegistry["realtimeTranscriptionProviders"];
|
|
realtimeVoiceProviders: PluginRegistry["realtimeVoiceProviders"];
|
|
mediaUnderstandingProviders: PluginRegistry["mediaUnderstandingProviders"];
|
|
imageGenerationProviders: PluginRegistry["imageGenerationProviders"];
|
|
videoGenerationProviders: PluginRegistry["videoGenerationProviders"];
|
|
musicGenerationProviders: PluginRegistry["musicGenerationProviders"];
|
|
webFetchProviders: PluginRegistry["webFetchProviders"];
|
|
webSearchProviders: PluginRegistry["webSearchProviders"];
|
|
codexAppServerExtensionFactories: PluginRegistry["codexAppServerExtensionFactories"];
|
|
agentToolResultMiddlewares: PluginRegistry["agentToolResultMiddlewares"];
|
|
memoryEmbeddingProviders: PluginRegistry["memoryEmbeddingProviders"];
|
|
agentHarnesses: PluginRegistry["agentHarnesses"];
|
|
httpRoutes: PluginRegistry["httpRoutes"];
|
|
cliRegistrars: PluginRegistry["cliRegistrars"];
|
|
reloads: NonNullable<PluginRegistry["reloads"]>;
|
|
nodeHostCommands: NonNullable<PluginRegistry["nodeHostCommands"]>;
|
|
securityAuditCollectors: NonNullable<PluginRegistry["securityAuditCollectors"]>;
|
|
services: PluginRegistry["services"];
|
|
commands: PluginRegistry["commands"];
|
|
conversationBindingResolvedHandlers: PluginRegistry["conversationBindingResolvedHandlers"];
|
|
diagnostics: PluginRegistry["diagnostics"];
|
|
};
|
|
gatewayHandlers: PluginRegistry["gatewayHandlers"];
|
|
gatewayMethodScopes: NonNullable<PluginRegistry["gatewayMethodScopes"]>;
|
|
};
|
|
|
|
function snapshotPluginRegistry(registry: PluginRegistry): PluginRegistrySnapshot {
|
|
return {
|
|
arrays: {
|
|
tools: [...registry.tools],
|
|
hooks: [...registry.hooks],
|
|
typedHooks: [...registry.typedHooks],
|
|
channels: [...registry.channels],
|
|
channelSetups: [...registry.channelSetups],
|
|
providers: [...registry.providers],
|
|
cliBackends: [...(registry.cliBackends ?? [])],
|
|
textTransforms: [...registry.textTransforms],
|
|
speechProviders: [...registry.speechProviders],
|
|
realtimeTranscriptionProviders: [...registry.realtimeTranscriptionProviders],
|
|
realtimeVoiceProviders: [...registry.realtimeVoiceProviders],
|
|
mediaUnderstandingProviders: [...registry.mediaUnderstandingProviders],
|
|
imageGenerationProviders: [...registry.imageGenerationProviders],
|
|
videoGenerationProviders: [...registry.videoGenerationProviders],
|
|
musicGenerationProviders: [...registry.musicGenerationProviders],
|
|
webFetchProviders: [...registry.webFetchProviders],
|
|
webSearchProviders: [...registry.webSearchProviders],
|
|
codexAppServerExtensionFactories: [...registry.codexAppServerExtensionFactories],
|
|
agentToolResultMiddlewares: [...registry.agentToolResultMiddlewares],
|
|
memoryEmbeddingProviders: [...registry.memoryEmbeddingProviders],
|
|
agentHarnesses: [...registry.agentHarnesses],
|
|
httpRoutes: [...registry.httpRoutes],
|
|
cliRegistrars: [...registry.cliRegistrars],
|
|
reloads: [...(registry.reloads ?? [])],
|
|
nodeHostCommands: [...(registry.nodeHostCommands ?? [])],
|
|
securityAuditCollectors: [...(registry.securityAuditCollectors ?? [])],
|
|
services: [...registry.services],
|
|
commands: [...registry.commands],
|
|
conversationBindingResolvedHandlers: [...registry.conversationBindingResolvedHandlers],
|
|
diagnostics: [...registry.diagnostics],
|
|
},
|
|
gatewayHandlers: { ...registry.gatewayHandlers },
|
|
gatewayMethodScopes: { ...registry.gatewayMethodScopes },
|
|
};
|
|
}
|
|
|
|
function restorePluginRegistry(registry: PluginRegistry, snapshot: PluginRegistrySnapshot): void {
|
|
registry.tools = snapshot.arrays.tools;
|
|
registry.hooks = snapshot.arrays.hooks;
|
|
registry.typedHooks = snapshot.arrays.typedHooks;
|
|
registry.channels = snapshot.arrays.channels;
|
|
registry.channelSetups = snapshot.arrays.channelSetups;
|
|
registry.providers = snapshot.arrays.providers;
|
|
registry.cliBackends = snapshot.arrays.cliBackends;
|
|
registry.textTransforms = snapshot.arrays.textTransforms;
|
|
registry.speechProviders = snapshot.arrays.speechProviders;
|
|
registry.realtimeTranscriptionProviders = snapshot.arrays.realtimeTranscriptionProviders;
|
|
registry.realtimeVoiceProviders = snapshot.arrays.realtimeVoiceProviders;
|
|
registry.mediaUnderstandingProviders = snapshot.arrays.mediaUnderstandingProviders;
|
|
registry.imageGenerationProviders = snapshot.arrays.imageGenerationProviders;
|
|
registry.videoGenerationProviders = snapshot.arrays.videoGenerationProviders;
|
|
registry.musicGenerationProviders = snapshot.arrays.musicGenerationProviders;
|
|
registry.webFetchProviders = snapshot.arrays.webFetchProviders;
|
|
registry.webSearchProviders = snapshot.arrays.webSearchProviders;
|
|
registry.codexAppServerExtensionFactories = snapshot.arrays.codexAppServerExtensionFactories;
|
|
registry.agentToolResultMiddlewares = snapshot.arrays.agentToolResultMiddlewares;
|
|
registry.memoryEmbeddingProviders = snapshot.arrays.memoryEmbeddingProviders;
|
|
registry.agentHarnesses = snapshot.arrays.agentHarnesses;
|
|
registry.httpRoutes = snapshot.arrays.httpRoutes;
|
|
registry.cliRegistrars = snapshot.arrays.cliRegistrars;
|
|
registry.reloads = snapshot.arrays.reloads;
|
|
registry.nodeHostCommands = snapshot.arrays.nodeHostCommands;
|
|
registry.securityAuditCollectors = snapshot.arrays.securityAuditCollectors;
|
|
registry.services = snapshot.arrays.services;
|
|
registry.commands = snapshot.arrays.commands;
|
|
registry.conversationBindingResolvedHandlers =
|
|
snapshot.arrays.conversationBindingResolvedHandlers;
|
|
registry.diagnostics = snapshot.arrays.diagnostics;
|
|
registry.gatewayHandlers = snapshot.gatewayHandlers;
|
|
registry.gatewayMethodScopes = snapshot.gatewayMethodScopes;
|
|
}
|
|
|
|
function createGuardedPluginRegistrationApi(api: OpenClawPluginApi): {
|
|
api: OpenClawPluginApi;
|
|
close: () => void;
|
|
} {
|
|
let closed = false;
|
|
return {
|
|
api: new Proxy(api, {
|
|
get(target, prop, receiver) {
|
|
const value = Reflect.get(target, prop, receiver);
|
|
if (typeof value !== "function") {
|
|
return value;
|
|
}
|
|
return (...args: unknown[]) => {
|
|
if (closed) {
|
|
return undefined;
|
|
}
|
|
return Reflect.apply(value, target, args);
|
|
};
|
|
},
|
|
}),
|
|
close: () => {
|
|
closed = true;
|
|
},
|
|
};
|
|
}
|
|
|
|
function runPluginRegisterSync(
|
|
register: NonNullable<OpenClawPluginDefinition["register"]>,
|
|
api: Parameters<NonNullable<OpenClawPluginDefinition["register"]>>[0],
|
|
): void {
|
|
const guarded = createGuardedPluginRegistrationApi(api);
|
|
try {
|
|
const result = register(guarded.api);
|
|
if (isPromiseLike(result)) {
|
|
void Promise.resolve(result).catch(() => {});
|
|
throw new Error("plugin register must be synchronous");
|
|
}
|
|
} finally {
|
|
guarded.close();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* On Windows, the Node.js ESM loader requires absolute paths to be expressed
|
|
* as file:// URLs (e.g. file:///C:/Users/...). Raw drive-letter paths like
|
|
* C:\... are rejected with ERR_UNSUPPORTED_ESM_URL_SCHEME because the loader
|
|
* mistakes the drive letter for an unknown URL scheme.
|
|
*
|
|
* This helper converts Windows absolute import specifiers to file:// URLs and
|
|
* leaves everything else unchanged.
|
|
*/
|
|
function toSafeImportPath(specifier: string): string {
|
|
if (process.platform !== "win32") {
|
|
return specifier;
|
|
}
|
|
if (specifier.startsWith("file://")) {
|
|
return specifier;
|
|
}
|
|
if (path.win32.isAbsolute(specifier)) {
|
|
const normalizedSpecifier = specifier.replaceAll("\\", "/");
|
|
if (normalizedSpecifier.startsWith("//")) {
|
|
return new URL(`file:${encodeURI(normalizedSpecifier)}`).href;
|
|
}
|
|
return new URL(`file:///${encodeURI(normalizedSpecifier)}`).href;
|
|
}
|
|
return specifier;
|
|
}
|
|
|
|
type RuntimeDependencyPackageJson = {
|
|
dependencies?: Record<string, unknown>;
|
|
optionalDependencies?: Record<string, unknown>;
|
|
peerDependencies?: Record<string, unknown>;
|
|
exports?: unknown;
|
|
module?: string;
|
|
main?: string;
|
|
};
|
|
|
|
const bundledRuntimeDependencyJitiAliases = new Map<string, string>();
|
|
|
|
function readRuntimeDependencyPackageJson(
|
|
packageJsonPath: string,
|
|
): RuntimeDependencyPackageJson | null {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as RuntimeDependencyPackageJson;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function collectRuntimeDependencyNames(pkg: RuntimeDependencyPackageJson): string[] {
|
|
return [
|
|
...Object.keys(pkg.dependencies ?? {}),
|
|
...Object.keys(pkg.optionalDependencies ?? {}),
|
|
...Object.keys(pkg.peerDependencies ?? {}),
|
|
].toSorted((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
function resolveRuntimePackageImportTarget(exportsField: unknown): string | null {
|
|
if (typeof exportsField === "string") {
|
|
return exportsField;
|
|
}
|
|
if (Array.isArray(exportsField)) {
|
|
for (const entry of exportsField) {
|
|
const resolved = resolveRuntimePackageImportTarget(entry);
|
|
if (resolved) {
|
|
return resolved;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
if (!exportsField || typeof exportsField !== "object" || Array.isArray(exportsField)) {
|
|
return null;
|
|
}
|
|
const record = exportsField as Record<string, unknown>;
|
|
if (Object.prototype.hasOwnProperty.call(record, ".")) {
|
|
return resolveRuntimePackageImportTarget(record["."]);
|
|
}
|
|
for (const condition of ["import", "node", "default"] as const) {
|
|
const resolved = resolveRuntimePackageImportTarget(record[condition]);
|
|
if (resolved) {
|
|
return resolved;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function collectRuntimePackageWildcardImportTargets(
|
|
dependencyRoot: string,
|
|
exportKey: string,
|
|
targetPattern: string,
|
|
): Map<string, string> {
|
|
const targets = new Map<string, string>();
|
|
const wildcardIndex = exportKey.indexOf("*");
|
|
const targetWildcardIndex = targetPattern.indexOf("*");
|
|
if (wildcardIndex === -1 || targetWildcardIndex === -1) {
|
|
return targets;
|
|
}
|
|
const exportPrefix = exportKey.slice(0, wildcardIndex);
|
|
const exportSuffix = exportKey.slice(wildcardIndex + 1);
|
|
const targetPrefix = targetPattern.slice(0, targetWildcardIndex);
|
|
const targetSuffix = targetPattern.slice(targetWildcardIndex + 1);
|
|
const targetBase = path.resolve(dependencyRoot, targetPrefix);
|
|
if (!isPathInside(dependencyRoot, targetBase) || !safeStatSync(targetBase)?.isDirectory()) {
|
|
return targets;
|
|
}
|
|
const stack = [targetBase];
|
|
while (stack.length > 0) {
|
|
const currentDir = stack.pop();
|
|
if (!currentDir) {
|
|
continue;
|
|
}
|
|
let entries: fs.Dirent[];
|
|
try {
|
|
entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const entry of entries) {
|
|
const entryPath = path.join(currentDir, entry.name);
|
|
if (!isPathInside(dependencyRoot, entryPath)) {
|
|
continue;
|
|
}
|
|
if (entry.isDirectory()) {
|
|
stack.push(entryPath);
|
|
continue;
|
|
}
|
|
if (!entry.isFile()) {
|
|
continue;
|
|
}
|
|
const relativeTarget = path.relative(targetBase, entryPath).split(path.sep).join("/");
|
|
if (targetSuffix && !relativeTarget.endsWith(targetSuffix)) {
|
|
continue;
|
|
}
|
|
const wildcardValue = targetSuffix
|
|
? relativeTarget.slice(0, -targetSuffix.length)
|
|
: relativeTarget;
|
|
targets.set(`${exportPrefix}${wildcardValue}${exportSuffix}`, entryPath);
|
|
}
|
|
}
|
|
return targets;
|
|
}
|
|
|
|
function collectRuntimePackageImportTargets(
|
|
dependencyRoot: string,
|
|
pkg: RuntimeDependencyPackageJson,
|
|
): Map<string, string> {
|
|
const targets = new Map<string, string>();
|
|
const exportsField = pkg.exports;
|
|
if (
|
|
exportsField &&
|
|
typeof exportsField === "object" &&
|
|
!Array.isArray(exportsField) &&
|
|
Object.keys(exportsField).some((key) => key.startsWith("."))
|
|
) {
|
|
for (const [exportKey, exportValue] of Object.entries(exportsField)) {
|
|
if (!exportKey.startsWith(".")) {
|
|
continue;
|
|
}
|
|
const resolved = resolveRuntimePackageImportTarget(exportValue);
|
|
if (resolved) {
|
|
if (exportKey.includes("*")) {
|
|
for (const [wildcardExportKey, targetPath] of collectRuntimePackageWildcardImportTargets(
|
|
dependencyRoot,
|
|
exportKey,
|
|
resolved,
|
|
)) {
|
|
targets.set(wildcardExportKey, targetPath);
|
|
}
|
|
} else {
|
|
targets.set(exportKey, resolved);
|
|
}
|
|
}
|
|
}
|
|
return targets;
|
|
}
|
|
const rootEntry = resolveRuntimePackageImportTarget(exportsField) ?? pkg.module ?? pkg.main;
|
|
if (rootEntry) {
|
|
targets.set(".", rootEntry);
|
|
}
|
|
return targets;
|
|
}
|
|
|
|
function registerBundledRuntimeDependencyJitiAliases(rootDir: string): void {
|
|
const rootPackageJson = readRuntimeDependencyPackageJson(path.join(rootDir, "package.json"));
|
|
if (!rootPackageJson) {
|
|
return;
|
|
}
|
|
for (const dependencyName of collectRuntimeDependencyNames(rootPackageJson)) {
|
|
const dependencyPackageJsonPath = path.join(
|
|
rootDir,
|
|
"node_modules",
|
|
...dependencyName.split("/"),
|
|
"package.json",
|
|
);
|
|
const dependencyPackageJson = readRuntimeDependencyPackageJson(dependencyPackageJsonPath);
|
|
if (!dependencyPackageJson) {
|
|
continue;
|
|
}
|
|
const dependencyRoot = path.dirname(dependencyPackageJsonPath);
|
|
for (const [exportKey, entry] of collectRuntimePackageImportTargets(
|
|
dependencyRoot,
|
|
dependencyPackageJson,
|
|
)) {
|
|
if (!entry || entry.startsWith("#")) {
|
|
continue;
|
|
}
|
|
const targetPath = path.resolve(dependencyRoot, entry);
|
|
if (!isPathInside(dependencyRoot, targetPath) || !fs.existsSync(targetPath)) {
|
|
continue;
|
|
}
|
|
const aliasKey =
|
|
exportKey === "." ? dependencyName : `${dependencyName}${exportKey.slice(1)}`;
|
|
bundledRuntimeDependencyJitiAliases.set(aliasKey, normalizeJitiAliasTargetPath(targetPath));
|
|
}
|
|
}
|
|
}
|
|
|
|
function resolveBundledRuntimeDependencyJitiAliasMap(): Record<string, string> | undefined {
|
|
if (bundledRuntimeDependencyJitiAliases.size === 0) {
|
|
return undefined;
|
|
}
|
|
return Object.fromEntries(
|
|
[...bundledRuntimeDependencyJitiAliases.entries()].toSorted(
|
|
([left], [right]) => right.length - left.length || left.localeCompare(right),
|
|
),
|
|
);
|
|
}
|
|
|
|
function createPluginJitiLoader(options: Pick<PluginLoadOptions, "pluginSdkResolution">) {
|
|
const jitiLoaders: PluginJitiLoaderCache = new Map();
|
|
return (modulePath: string) => {
|
|
const tryNative = shouldPreferNativeJiti(modulePath);
|
|
const runtimeAliasMap = resolveBundledRuntimeDependencyJitiAliasMap();
|
|
return getCachedPluginJitiLoader({
|
|
cache: jitiLoaders,
|
|
modulePath,
|
|
importerUrl: import.meta.url,
|
|
jitiFilename: modulePath,
|
|
...(runtimeAliasMap
|
|
? {
|
|
aliasMap: {
|
|
...buildPluginLoaderAliasMap(
|
|
modulePath,
|
|
process.argv[1],
|
|
import.meta.url,
|
|
options.pluginSdkResolution,
|
|
),
|
|
...runtimeAliasMap,
|
|
},
|
|
}
|
|
: {}),
|
|
pluginSdkResolution: options.pluginSdkResolution,
|
|
// Source .ts runtime shims import sibling ".js" specifiers that only exist
|
|
// after build. Disable native loading for source entries so Jiti rewrites
|
|
// those imports against the source graph, while keeping native dist/*.js
|
|
// loading for the canonical built module graph.
|
|
tryNative,
|
|
});
|
|
};
|
|
}
|
|
|
|
function resolveCanonicalDistRuntimeSource(source: string): string {
|
|
const marker = `${path.sep}dist-runtime${path.sep}extensions${path.sep}`;
|
|
const index = source.indexOf(marker);
|
|
if (index === -1) {
|
|
return source;
|
|
}
|
|
const candidate = `${source.slice(0, index)}${path.sep}dist${path.sep}extensions${path.sep}${source.slice(index + marker.length)}`;
|
|
return fs.existsSync(candidate) ? candidate : source;
|
|
}
|
|
|
|
function mirrorBundledPluginRuntimeRoot(params: {
|
|
pluginId: string;
|
|
pluginRoot: string;
|
|
installRoot: string;
|
|
}): string {
|
|
return withBundledRuntimeDepsFilesystemLock(
|
|
params.installRoot,
|
|
BUNDLED_RUNTIME_MIRROR_LOCK_DIR,
|
|
() => {
|
|
const mirrorParent = prepareBundledPluginRuntimeDistMirror({
|
|
installRoot: params.installRoot,
|
|
pluginRoot: params.pluginRoot,
|
|
});
|
|
const mirrorRoot = path.join(mirrorParent, params.pluginId);
|
|
fs.mkdirSync(params.installRoot, { recursive: true });
|
|
try {
|
|
fs.chmodSync(params.installRoot, 0o755);
|
|
} catch {
|
|
// Best-effort only: staged roots may live on filesystems that reject chmod.
|
|
}
|
|
fs.mkdirSync(mirrorParent, { recursive: true });
|
|
try {
|
|
fs.chmodSync(mirrorParent, 0o755);
|
|
} catch {
|
|
// Best-effort only: the access check below will surface non-writable dirs.
|
|
}
|
|
fs.accessSync(mirrorParent, fs.constants.W_OK);
|
|
const tempDir = fs.mkdtempSync(path.join(mirrorParent, `.plugin-${params.pluginId}-`));
|
|
const stagedRoot = path.join(tempDir, "plugin");
|
|
try {
|
|
copyBundledPluginRuntimeRoot(params.pluginRoot, stagedRoot);
|
|
fs.rmSync(mirrorRoot, { recursive: true, force: true });
|
|
fs.renameSync(stagedRoot, mirrorRoot);
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
return mirrorRoot;
|
|
},
|
|
);
|
|
}
|
|
|
|
function prepareBundledPluginRuntimeDistMirror(params: {
|
|
installRoot: string;
|
|
pluginRoot: string;
|
|
}): string {
|
|
const sourceExtensionsRoot = path.dirname(params.pluginRoot);
|
|
const sourceDistRoot = path.dirname(sourceExtensionsRoot);
|
|
const sourceDistRootName = path.basename(sourceDistRoot);
|
|
const mirrorDistRoot = path.join(params.installRoot, sourceDistRootName);
|
|
const mirrorExtensionsRoot = path.join(mirrorDistRoot, "extensions");
|
|
fs.mkdirSync(mirrorExtensionsRoot, { recursive: true, mode: 0o755 });
|
|
ensureBundledRuntimeDistPackageJson(mirrorDistRoot);
|
|
for (const entry of fs.readdirSync(sourceDistRoot, { withFileTypes: true })) {
|
|
if (entry.name === "extensions") {
|
|
continue;
|
|
}
|
|
const sourcePath = path.join(sourceDistRoot, entry.name);
|
|
const targetPath = path.join(mirrorDistRoot, entry.name);
|
|
if (fs.existsSync(targetPath)) {
|
|
continue;
|
|
}
|
|
try {
|
|
fs.symlinkSync(sourcePath, targetPath, entry.isDirectory() ? "junction" : "file");
|
|
} catch {
|
|
if (fs.existsSync(targetPath)) {
|
|
continue;
|
|
}
|
|
if (entry.isDirectory()) {
|
|
copyBundledPluginRuntimeRoot(sourcePath, targetPath);
|
|
} else if (entry.isFile()) {
|
|
fs.copyFileSync(sourcePath, targetPath);
|
|
}
|
|
}
|
|
}
|
|
if (sourceDistRootName === "dist-runtime") {
|
|
const sourceCanonicalDistRoot = path.join(path.dirname(sourceDistRoot), "dist");
|
|
const targetCanonicalDistRoot = path.join(params.installRoot, "dist");
|
|
if (fs.existsSync(sourceCanonicalDistRoot)) {
|
|
const targetMatchesSource =
|
|
fs.existsSync(targetCanonicalDistRoot) &&
|
|
safeRealpathOrResolve(targetCanonicalDistRoot) ===
|
|
safeRealpathOrResolve(sourceCanonicalDistRoot);
|
|
if (!targetMatchesSource) {
|
|
fs.rmSync(targetCanonicalDistRoot, { recursive: true, force: true });
|
|
try {
|
|
fs.symlinkSync(sourceCanonicalDistRoot, targetCanonicalDistRoot, "junction");
|
|
} catch {
|
|
copyBundledPluginRuntimeRoot(sourceCanonicalDistRoot, targetCanonicalDistRoot);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ensureOpenClawPluginSdkAlias(mirrorDistRoot);
|
|
return mirrorExtensionsRoot;
|
|
}
|
|
|
|
function ensureBundledRuntimeDistPackageJson(mirrorDistRoot: string): void {
|
|
const packageJsonPath = path.join(mirrorDistRoot, "package.json");
|
|
if (fs.existsSync(packageJsonPath)) {
|
|
return;
|
|
}
|
|
writeRuntimeJsonFile(packageJsonPath, { type: "module" });
|
|
}
|
|
|
|
function copyBundledPluginRuntimeRoot(sourceRoot: string, targetRoot: string): void {
|
|
fs.mkdirSync(targetRoot, { recursive: true, mode: 0o755 });
|
|
for (const entry of fs.readdirSync(sourceRoot, { withFileTypes: true })) {
|
|
if (entry.name === "node_modules") {
|
|
continue;
|
|
}
|
|
const sourcePath = path.join(sourceRoot, entry.name);
|
|
const targetPath = path.join(targetRoot, entry.name);
|
|
if (entry.isDirectory()) {
|
|
copyBundledPluginRuntimeRoot(sourcePath, targetPath);
|
|
continue;
|
|
}
|
|
if (entry.isSymbolicLink()) {
|
|
fs.symlinkSync(fs.readlinkSync(sourcePath), targetPath);
|
|
continue;
|
|
}
|
|
if (!entry.isFile()) {
|
|
continue;
|
|
}
|
|
fs.copyFileSync(sourcePath, targetPath);
|
|
try {
|
|
const sourceMode = fs.statSync(sourcePath).mode;
|
|
fs.chmodSync(targetPath, sourceMode | 0o600);
|
|
} catch {
|
|
// Readable copied files are enough for plugin loading.
|
|
}
|
|
}
|
|
}
|
|
|
|
function writeRuntimeJsonFile(targetPath: string, value: unknown): void {
|
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
fs.writeFileSync(targetPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
function hasRuntimeDefaultExport(sourcePath: string): boolean {
|
|
const text = fs.readFileSync(sourcePath, "utf8");
|
|
return /\bexport\s+default\b/u.test(text) || /\bas\s+default\b/u.test(text);
|
|
}
|
|
|
|
function writeRuntimeModuleWrapper(sourcePath: string, targetPath: string): void {
|
|
const specifier = path.relative(path.dirname(targetPath), sourcePath).replaceAll(path.sep, "/");
|
|
const normalizedSpecifier = specifier.startsWith(".") ? specifier : `./${specifier}`;
|
|
const defaultForwarder = hasRuntimeDefaultExport(sourcePath)
|
|
? [
|
|
`import defaultModule from ${JSON.stringify(normalizedSpecifier)};`,
|
|
`let defaultExport = defaultModule;`,
|
|
`for (let index = 0; index < 4 && defaultExport && typeof defaultExport === "object" && "default" in defaultExport; index += 1) {`,
|
|
` defaultExport = defaultExport.default;`,
|
|
`}`,
|
|
]
|
|
: [
|
|
`import * as module from ${JSON.stringify(normalizedSpecifier)};`,
|
|
`let defaultExport = "default" in module ? module.default : module;`,
|
|
`for (let index = 0; index < 4 && defaultExport && typeof defaultExport === "object" && "default" in defaultExport; index += 1) {`,
|
|
` defaultExport = defaultExport.default;`,
|
|
`}`,
|
|
];
|
|
const content = [
|
|
`export * from ${JSON.stringify(normalizedSpecifier)};`,
|
|
...defaultForwarder,
|
|
"export { defaultExport as default };",
|
|
"",
|
|
].join("\n");
|
|
try {
|
|
if (fs.readFileSync(targetPath, "utf8") === content) {
|
|
return;
|
|
}
|
|
} catch {
|
|
// Missing or unreadable wrapper; rewrite below.
|
|
}
|
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
fs.writeFileSync(targetPath, content, "utf8");
|
|
}
|
|
|
|
function ensureOpenClawPluginSdkAlias(distRoot: string): void {
|
|
const pluginSdkDir = path.join(distRoot, "plugin-sdk");
|
|
if (!fs.existsSync(pluginSdkDir)) {
|
|
return;
|
|
}
|
|
|
|
const aliasDir = path.join(distRoot, "extensions", "node_modules", "openclaw");
|
|
const pluginSdkAliasDir = path.join(aliasDir, "plugin-sdk");
|
|
writeRuntimeJsonFile(path.join(aliasDir, "package.json"), {
|
|
name: "openclaw",
|
|
type: "module",
|
|
exports: {
|
|
"./plugin-sdk": "./plugin-sdk/index.js",
|
|
"./plugin-sdk/*": "./plugin-sdk/*.js",
|
|
},
|
|
});
|
|
try {
|
|
if (fs.existsSync(pluginSdkAliasDir) && !fs.lstatSync(pluginSdkAliasDir).isDirectory()) {
|
|
fs.rmSync(pluginSdkAliasDir, { recursive: true, force: true });
|
|
}
|
|
} catch {
|
|
// Another process may be creating the alias at the same time; mkdir/write
|
|
// below will either converge or surface the real filesystem error.
|
|
}
|
|
fs.mkdirSync(pluginSdkAliasDir, { recursive: true });
|
|
for (const entry of fs.readdirSync(pluginSdkDir, { withFileTypes: true })) {
|
|
if (!entry.isFile() || path.extname(entry.name) !== ".js") {
|
|
continue;
|
|
}
|
|
writeRuntimeModuleWrapper(
|
|
path.join(pluginSdkDir, entry.name),
|
|
path.join(pluginSdkAliasDir, entry.name),
|
|
);
|
|
}
|
|
}
|
|
|
|
function remapBundledPluginRuntimePath(params: {
|
|
source: string | undefined;
|
|
pluginRoot: string;
|
|
mirroredRoot: string;
|
|
}): string | undefined {
|
|
if (!params.source) {
|
|
return undefined;
|
|
}
|
|
const relative = path.relative(params.pluginRoot, params.source);
|
|
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
return params.source;
|
|
}
|
|
return path.join(params.mirroredRoot, relative);
|
|
}
|
|
|
|
export const __testing = {
|
|
buildPluginLoaderJitiOptions,
|
|
buildPluginLoaderAliasMap,
|
|
listPluginSdkAliasCandidates,
|
|
listPluginSdkExportedSubpaths,
|
|
resolveExtensionApiAlias,
|
|
resolvePluginSdkScopedAliasMap,
|
|
resolvePluginSdkAliasCandidateOrder,
|
|
resolvePluginSdkAliasFile,
|
|
resolvePluginRuntimeModulePath,
|
|
ensureOpenClawPluginSdkAlias,
|
|
shouldLoadChannelPluginInSetupRuntime,
|
|
shouldPreferNativeJiti,
|
|
toSafeImportPath,
|
|
getCompatibleActivePluginRegistry,
|
|
resolvePluginLoadCacheContext,
|
|
get maxPluginRegistryCacheEntries() {
|
|
return pluginRegistryCacheEntryCap;
|
|
},
|
|
setMaxPluginRegistryCacheEntriesForTest(value?: number) {
|
|
pluginRegistryCacheEntryCap =
|
|
typeof value === "number" && Number.isFinite(value) && value > 0
|
|
? Math.max(1, Math.floor(value))
|
|
: MAX_PLUGIN_REGISTRY_CACHE_ENTRIES;
|
|
},
|
|
};
|
|
|
|
function getCachedPluginRegistry(cacheKey: string): CachedPluginState | 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, state: CachedPluginState): void {
|
|
if (registryCache.has(cacheKey)) {
|
|
registryCache.delete(cacheKey);
|
|
}
|
|
registryCache.set(cacheKey, state);
|
|
while (registryCache.size > pluginRegistryCacheEntryCap) {
|
|
const oldestKey = registryCache.keys().next().value;
|
|
if (!oldestKey) {
|
|
break;
|
|
}
|
|
registryCache.delete(oldestKey);
|
|
}
|
|
}
|
|
|
|
function buildCacheKey(params: {
|
|
workspaceDir?: string;
|
|
plugins: NormalizedPluginsConfig;
|
|
activationMetadataKey?: string;
|
|
installs?: Record<string, PluginInstallRecord>;
|
|
env: NodeJS.ProcessEnv;
|
|
onlyPluginIds?: string[];
|
|
includeSetupOnlyChannelPlugins?: boolean;
|
|
forceSetupOnlyChannelPlugins?: boolean;
|
|
requireSetupEntryForSetupOnlyChannelPlugins?: boolean;
|
|
preferSetupRuntimeForChannelPlugins?: boolean;
|
|
loadModules?: boolean;
|
|
installBundledRuntimeDeps?: boolean;
|
|
runtimeSubagentMode?: "default" | "explicit" | "gateway-bindable";
|
|
pluginSdkResolution?: PluginSdkResolutionPreference;
|
|
coreGatewayMethodNames?: string[];
|
|
activate?: boolean;
|
|
}): 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,
|
|
},
|
|
]),
|
|
);
|
|
const scopeKey = serializePluginIdScope(params.onlyPluginIds);
|
|
const setupOnlyKey = params.includeSetupOnlyChannelPlugins === true ? "setup-only" : "runtime";
|
|
const setupOnlyModeKey =
|
|
params.forceSetupOnlyChannelPlugins === true ? "force-setup" : "normal-setup";
|
|
const setupOnlyRequirementKey =
|
|
params.requireSetupEntryForSetupOnlyChannelPlugins === true
|
|
? "require-setup-entry"
|
|
: "allow-full-fallback";
|
|
const startupChannelMode =
|
|
params.preferSetupRuntimeForChannelPlugins === true ? "prefer-setup" : "full";
|
|
const moduleLoadMode = params.loadModules === false ? "manifest-only" : "load-modules";
|
|
const bundledRuntimeDepsMode =
|
|
params.installBundledRuntimeDeps === false ? "skip-runtime-deps" : "install-runtime-deps";
|
|
const runtimeSubagentMode = params.runtimeSubagentMode ?? "default";
|
|
const gatewayMethodsKey = JSON.stringify(params.coreGatewayMethodNames ?? []);
|
|
const activationMode = params.activate === false ? "snapshot" : "active";
|
|
return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({
|
|
...params.plugins,
|
|
installs,
|
|
loadPaths,
|
|
activationMetadataKey: params.activationMetadataKey ?? "",
|
|
})}::${scopeKey}::${setupOnlyKey}::${setupOnlyModeKey}::${setupOnlyRequirementKey}::${startupChannelMode}::${moduleLoadMode}::${bundledRuntimeDepsMode}::${runtimeSubagentMode}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}::${activationMode}`;
|
|
}
|
|
|
|
function matchesScopedPluginRequest(params: {
|
|
onlyPluginIdSet: ReadonlySet<string> | null;
|
|
pluginId: string;
|
|
}): boolean {
|
|
const scopedIds = params.onlyPluginIdSet;
|
|
if (!scopedIds) {
|
|
return true;
|
|
}
|
|
return scopedIds.has(params.pluginId);
|
|
}
|
|
|
|
function resolveRuntimeSubagentMode(
|
|
runtimeOptions: PluginLoadOptions["runtimeOptions"],
|
|
): "default" | "explicit" | "gateway-bindable" {
|
|
if (runtimeOptions?.allowGatewaySubagentBinding === true) {
|
|
return "gateway-bindable";
|
|
}
|
|
if (runtimeOptions?.subagent) {
|
|
return "explicit";
|
|
}
|
|
return "default";
|
|
}
|
|
|
|
function buildActivationMetadataHash(params: {
|
|
activationSource: PluginActivationConfigSource;
|
|
autoEnabledReasons: Readonly<Record<string, string[]>>;
|
|
}): string {
|
|
const enabledSourceChannels = Object.entries(
|
|
(params.activationSource.rootConfig?.channels as Record<string, unknown>) ?? {},
|
|
)
|
|
.filter(([, value]) => {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return false;
|
|
}
|
|
return (value as { enabled?: unknown }).enabled === true;
|
|
})
|
|
.map(([channelId]) => channelId)
|
|
.toSorted((left, right) => left.localeCompare(right));
|
|
const pluginEntryStates = Object.entries(params.activationSource.plugins.entries)
|
|
.map(([pluginId, entry]) => [pluginId, entry?.enabled ?? null] as const)
|
|
.toSorted(([left], [right]) => left.localeCompare(right));
|
|
const autoEnableReasonEntries = Object.entries(params.autoEnabledReasons)
|
|
.map(([pluginId, reasons]) => [pluginId, [...reasons]] as const)
|
|
.toSorted(([left], [right]) => left.localeCompare(right));
|
|
|
|
return createHash("sha256")
|
|
.update(
|
|
JSON.stringify({
|
|
enabled: params.activationSource.plugins.enabled,
|
|
allow: params.activationSource.plugins.allow,
|
|
deny: params.activationSource.plugins.deny,
|
|
memorySlot: params.activationSource.plugins.slots.memory,
|
|
entries: pluginEntryStates,
|
|
enabledChannels: enabledSourceChannels,
|
|
autoEnabledReasons: autoEnableReasonEntries,
|
|
}),
|
|
)
|
|
.digest("hex");
|
|
}
|
|
|
|
function hasExplicitCompatibilityInputs(options: PluginLoadOptions): boolean {
|
|
return (
|
|
options.config !== undefined ||
|
|
options.activationSourceConfig !== undefined ||
|
|
options.autoEnabledReasons !== undefined ||
|
|
options.workspaceDir !== undefined ||
|
|
options.env !== undefined ||
|
|
hasExplicitPluginIdScope(options.onlyPluginIds) ||
|
|
options.runtimeOptions !== undefined ||
|
|
options.pluginSdkResolution !== undefined ||
|
|
options.coreGatewayHandlers !== undefined ||
|
|
options.includeSetupOnlyChannelPlugins === true ||
|
|
options.forceSetupOnlyChannelPlugins === true ||
|
|
options.requireSetupEntryForSetupOnlyChannelPlugins === true ||
|
|
options.preferSetupRuntimeForChannelPlugins === true ||
|
|
options.installBundledRuntimeDeps === false ||
|
|
options.loadModules === false
|
|
);
|
|
}
|
|
|
|
type PluginRegistrationPlan = {
|
|
/** Public compatibility label passed to plugin register(api). */
|
|
mode: PluginRegistrationMode;
|
|
/** Load a setup entry instead of the normal runtime entry. */
|
|
loadSetupEntry: boolean;
|
|
/** Setup flow also needs the runtime channel entry for runtime setters/plugin shape. */
|
|
loadSetupRuntimeEntry: boolean;
|
|
/** Apply runtime capability policy such as memory-slot selection. */
|
|
runRuntimeCapabilityPolicy: boolean;
|
|
/** Register metadata that only belongs to live activation, not discovery snapshots. */
|
|
runFullActivationOnlyRegistrations: boolean;
|
|
};
|
|
|
|
/**
|
|
* Convert loader intent into explicit behavior flags.
|
|
*
|
|
* Registration modes are plugin-facing labels; this plan is the internal source
|
|
* of truth for which entrypoint to load and which activation-only policies run.
|
|
*/
|
|
function resolvePluginRegistrationPlan(params: {
|
|
canLoadScopedSetupOnlyChannelPlugin: boolean;
|
|
scopedSetupOnlyChannelPluginRequested: boolean;
|
|
requireSetupEntryForSetupOnlyChannelPlugins: boolean;
|
|
enableStateEnabled: boolean;
|
|
shouldLoadModules: boolean;
|
|
validateOnly: boolean;
|
|
shouldActivate: boolean;
|
|
manifestRecord: PluginManifestRecord;
|
|
cfg: OpenClawConfig;
|
|
env: NodeJS.ProcessEnv;
|
|
preferSetupRuntimeForChannelPlugins: boolean;
|
|
}): PluginRegistrationPlan | null {
|
|
if (params.canLoadScopedSetupOnlyChannelPlugin) {
|
|
return {
|
|
mode: "setup-only",
|
|
loadSetupEntry: true,
|
|
loadSetupRuntimeEntry: false,
|
|
runRuntimeCapabilityPolicy: false,
|
|
runFullActivationOnlyRegistrations: false,
|
|
};
|
|
}
|
|
if (
|
|
params.scopedSetupOnlyChannelPluginRequested &&
|
|
params.requireSetupEntryForSetupOnlyChannelPlugins
|
|
) {
|
|
return null;
|
|
}
|
|
if (!params.enableStateEnabled) {
|
|
return null;
|
|
}
|
|
const loadSetupRuntimeEntry =
|
|
params.shouldLoadModules &&
|
|
!params.validateOnly &&
|
|
shouldLoadChannelPluginInSetupRuntime({
|
|
manifestChannels: params.manifestRecord.channels,
|
|
setupSource: params.manifestRecord.setupSource,
|
|
startupDeferConfiguredChannelFullLoadUntilAfterListen:
|
|
params.manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen,
|
|
cfg: params.cfg,
|
|
env: params.env,
|
|
preferSetupRuntimeForChannelPlugins: params.preferSetupRuntimeForChannelPlugins,
|
|
});
|
|
if (loadSetupRuntimeEntry) {
|
|
return {
|
|
mode: "setup-runtime",
|
|
loadSetupEntry: true,
|
|
loadSetupRuntimeEntry: true,
|
|
runRuntimeCapabilityPolicy: false,
|
|
runFullActivationOnlyRegistrations: false,
|
|
};
|
|
}
|
|
const mode = params.shouldActivate ? "full" : "discovery";
|
|
return {
|
|
mode,
|
|
loadSetupEntry: false,
|
|
loadSetupRuntimeEntry: false,
|
|
runRuntimeCapabilityPolicy: true,
|
|
runFullActivationOnlyRegistrations: mode === "full",
|
|
};
|
|
}
|
|
|
|
function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
|
|
const env = options.env ?? process.env;
|
|
const cfg = applyTestPluginDefaults(options.config ?? {}, env);
|
|
const activationSourceConfig = resolvePluginActivationSourceConfig({
|
|
config: options.config,
|
|
activationSourceConfig: options.activationSourceConfig,
|
|
});
|
|
const normalized = normalizePluginsConfig(cfg.plugins);
|
|
const activationSource = createPluginActivationSource({
|
|
config: activationSourceConfig,
|
|
});
|
|
const trustNormalized = mergeTrustPluginConfigFromActivationSource({
|
|
normalized,
|
|
activationSource,
|
|
});
|
|
const onlyPluginIds = normalizePluginIdScope(options.onlyPluginIds);
|
|
const includeSetupOnlyChannelPlugins = options.includeSetupOnlyChannelPlugins === true;
|
|
const forceSetupOnlyChannelPlugins = options.forceSetupOnlyChannelPlugins === true;
|
|
const requireSetupEntryForSetupOnlyChannelPlugins =
|
|
options.requireSetupEntryForSetupOnlyChannelPlugins === true;
|
|
const preferSetupRuntimeForChannelPlugins = options.preferSetupRuntimeForChannelPlugins === true;
|
|
const shouldInstallBundledRuntimeDeps = options.installBundledRuntimeDeps !== false;
|
|
const runtimeSubagentMode = resolveRuntimeSubagentMode(options.runtimeOptions);
|
|
const coreGatewayMethodNames = Object.keys(options.coreGatewayHandlers ?? {}).toSorted();
|
|
const installRecords = {
|
|
...loadInstalledPluginIndexInstallRecordsSync({ env }),
|
|
...cfg.plugins?.installs,
|
|
};
|
|
const cacheKey = buildCacheKey({
|
|
workspaceDir: options.workspaceDir,
|
|
plugins: trustNormalized,
|
|
activationMetadataKey: buildActivationMetadataHash({
|
|
activationSource,
|
|
autoEnabledReasons: options.autoEnabledReasons ?? {},
|
|
}),
|
|
installs: installRecords,
|
|
env,
|
|
onlyPluginIds,
|
|
includeSetupOnlyChannelPlugins,
|
|
forceSetupOnlyChannelPlugins,
|
|
requireSetupEntryForSetupOnlyChannelPlugins,
|
|
preferSetupRuntimeForChannelPlugins,
|
|
loadModules: options.loadModules,
|
|
installBundledRuntimeDeps: options.installBundledRuntimeDeps,
|
|
runtimeSubagentMode,
|
|
pluginSdkResolution: options.pluginSdkResolution,
|
|
coreGatewayMethodNames,
|
|
activate: options.activate,
|
|
});
|
|
return {
|
|
env,
|
|
cfg,
|
|
normalized: trustNormalized,
|
|
activationSourceConfig,
|
|
activationSource,
|
|
autoEnabledReasons: options.autoEnabledReasons ?? {},
|
|
onlyPluginIds,
|
|
includeSetupOnlyChannelPlugins,
|
|
forceSetupOnlyChannelPlugins,
|
|
requireSetupEntryForSetupOnlyChannelPlugins,
|
|
preferSetupRuntimeForChannelPlugins,
|
|
shouldActivate: options.activate !== false,
|
|
shouldLoadModules: options.loadModules !== false,
|
|
shouldInstallBundledRuntimeDeps,
|
|
runtimeSubagentMode,
|
|
installRecords,
|
|
cacheKey,
|
|
};
|
|
}
|
|
|
|
function mergeTrustPluginConfigFromActivationSource(params: {
|
|
normalized: NormalizedPluginsConfig;
|
|
activationSource: PluginActivationConfigSource;
|
|
}): NormalizedPluginsConfig {
|
|
const source = params.activationSource.plugins;
|
|
const allow = mergePluginTrustList(params.normalized.allow, source.allow);
|
|
const deny = mergePluginTrustList(params.normalized.deny, source.deny);
|
|
const loadPaths = mergePluginTrustList(params.normalized.loadPaths, source.loadPaths);
|
|
if (
|
|
allow === params.normalized.allow &&
|
|
deny === params.normalized.deny &&
|
|
loadPaths === params.normalized.loadPaths
|
|
) {
|
|
return params.normalized;
|
|
}
|
|
return {
|
|
...params.normalized,
|
|
allow,
|
|
deny,
|
|
loadPaths,
|
|
};
|
|
}
|
|
|
|
function mergePluginTrustList(runtimeList: string[], sourceList: readonly string[]): string[] {
|
|
if (sourceList.length === 0) {
|
|
return runtimeList;
|
|
}
|
|
const merged = [...runtimeList];
|
|
const seen = new Set(merged);
|
|
for (const entry of sourceList) {
|
|
if (!seen.has(entry)) {
|
|
merged.push(entry);
|
|
seen.add(entry);
|
|
}
|
|
}
|
|
return merged.length === runtimeList.length ? runtimeList : merged;
|
|
}
|
|
|
|
function getCompatibleActivePluginRegistry(
|
|
options: PluginLoadOptions = {},
|
|
): PluginRegistry | undefined {
|
|
const activeRegistry = getActivePluginRegistry() ?? undefined;
|
|
if (!activeRegistry) {
|
|
return undefined;
|
|
}
|
|
if (!hasExplicitCompatibilityInputs(options)) {
|
|
return activeRegistry;
|
|
}
|
|
const activeCacheKey = getActivePluginRegistryKey();
|
|
if (!activeCacheKey) {
|
|
return undefined;
|
|
}
|
|
const loadContext = resolvePluginLoadCacheContext(options);
|
|
if (loadContext.cacheKey === activeCacheKey) {
|
|
return activeRegistry;
|
|
}
|
|
if (!loadContext.shouldActivate) {
|
|
const activatingCacheKey = resolvePluginLoadCacheContext({
|
|
...options,
|
|
activate: true,
|
|
}).cacheKey;
|
|
if (activatingCacheKey === activeCacheKey) {
|
|
return activeRegistry;
|
|
}
|
|
}
|
|
if (
|
|
loadContext.runtimeSubagentMode === "default" &&
|
|
getActivePluginRuntimeSubagentMode() === "gateway-bindable"
|
|
) {
|
|
const gatewayBindableCacheKey = resolvePluginLoadCacheContext({
|
|
...options,
|
|
runtimeOptions: {
|
|
...options.runtimeOptions,
|
|
allowGatewaySubagentBinding: true,
|
|
},
|
|
}).cacheKey;
|
|
if (gatewayBindableCacheKey === activeCacheKey) {
|
|
return activeRegistry;
|
|
}
|
|
if (!loadContext.shouldActivate) {
|
|
const activatingGatewayBindableCacheKey = resolvePluginLoadCacheContext({
|
|
...options,
|
|
activate: true,
|
|
runtimeOptions: {
|
|
...options.runtimeOptions,
|
|
allowGatewaySubagentBinding: true,
|
|
},
|
|
}).cacheKey;
|
|
if (activatingGatewayBindableCacheKey === activeCacheKey) {
|
|
return activeRegistry;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function resolveRuntimePluginRegistry(
|
|
options?: PluginLoadOptions,
|
|
): PluginRegistry | undefined {
|
|
if (!options || !hasExplicitCompatibilityInputs(options)) {
|
|
return getCompatibleActivePluginRegistry();
|
|
}
|
|
const compatible = getCompatibleActivePluginRegistry(options);
|
|
if (compatible) {
|
|
return compatible;
|
|
}
|
|
// Helper/runtime callers should not recurse into the same snapshot load while
|
|
// plugin registration is still in flight. Let direct loadOpenClawPlugins(...)
|
|
// callers surface the hard error instead.
|
|
if (isPluginRegistryLoadInFlight(options)) {
|
|
return undefined;
|
|
}
|
|
return loadOpenClawPlugins(options);
|
|
}
|
|
|
|
export function resolvePluginRegistryLoadCacheKey(options: PluginLoadOptions = {}): string {
|
|
return resolvePluginLoadCacheContext(options).cacheKey;
|
|
}
|
|
|
|
export function isPluginRegistryLoadInFlight(options: PluginLoadOptions = {}): boolean {
|
|
return inFlightPluginRegistryLoads.has(resolvePluginRegistryLoadCacheKey(options));
|
|
}
|
|
|
|
export function resolveCompatibleRuntimePluginRegistry(
|
|
options?: PluginLoadOptions,
|
|
): PluginRegistry | undefined {
|
|
// Check whether the active runtime registry is already compatible with these
|
|
// load options. Unlike resolveRuntimePluginRegistry, this never triggers a
|
|
// fresh plugin load on cache miss.
|
|
return getCompatibleActivePluginRegistry(options);
|
|
}
|
|
|
|
function validatePluginConfig(params: {
|
|
schema?: Record<string, unknown>;
|
|
cacheKey?: string;
|
|
value?: unknown;
|
|
}): { ok: boolean; value?: Record<string, unknown>; errors?: string[] } {
|
|
const schema = params.schema;
|
|
if (!schema) {
|
|
return { ok: true, value: params.value as Record<string, unknown> | undefined };
|
|
}
|
|
const cacheKey = params.cacheKey ?? JSON.stringify(schema);
|
|
const result = validateJsonSchemaValue({
|
|
schema,
|
|
cacheKey,
|
|
value: params.value ?? {},
|
|
applyDefaults: true,
|
|
});
|
|
if (result.ok) {
|
|
return { ok: true, value: result.value as Record<string, unknown> | undefined };
|
|
}
|
|
return { ok: false, errors: result.errors.map((error) => error.text) };
|
|
}
|
|
|
|
function resolvePluginModuleExport(moduleExport: unknown): {
|
|
definition?: OpenClawPluginDefinition;
|
|
register?: OpenClawPluginDefinition["register"];
|
|
} {
|
|
const seen = new Set<unknown>();
|
|
const candidates: unknown[] = [unwrapDefaultModuleExport(moduleExport), moduleExport];
|
|
for (let index = 0; index < candidates.length && index < 12; index += 1) {
|
|
const resolved = candidates[index];
|
|
if (seen.has(resolved)) {
|
|
continue;
|
|
}
|
|
seen.add(resolved);
|
|
if (typeof resolved === "function") {
|
|
return {
|
|
register: resolved as OpenClawPluginDefinition["register"],
|
|
};
|
|
}
|
|
if (resolved && typeof resolved === "object") {
|
|
const def = resolved as OpenClawPluginDefinition;
|
|
const register = def.register ?? def.activate;
|
|
if (typeof register === "function") {
|
|
return { definition: def, register };
|
|
}
|
|
for (const key of ["default", "module"]) {
|
|
if (key in def) {
|
|
candidates.push((def as Record<string, unknown>)[key]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const resolved = candidates[0];
|
|
if (typeof resolved === "function") {
|
|
return {
|
|
register: resolved as OpenClawPluginDefinition["register"],
|
|
};
|
|
}
|
|
if (resolved && typeof resolved === "object") {
|
|
const def = resolved as OpenClawPluginDefinition;
|
|
const register = def.register ?? def.activate;
|
|
return { definition: def, register };
|
|
}
|
|
return {};
|
|
}
|
|
|
|
function isPluginLoadDebugEnabled(env: NodeJS.ProcessEnv): boolean {
|
|
const normalized = normalizeLowercaseStringOrEmpty(env.OPENCLAW_PLUGIN_LOAD_DEBUG);
|
|
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
|
}
|
|
|
|
function describePluginModuleExportShape(
|
|
value: unknown,
|
|
label = "export",
|
|
seen: Set<unknown> = new Set(),
|
|
): string[] {
|
|
if (value === null) {
|
|
return [`${label}:null`];
|
|
}
|
|
if (typeof value !== "object") {
|
|
return [`${label}:${typeof value}`];
|
|
}
|
|
if (seen.has(value)) {
|
|
return [`${label}:circular`];
|
|
}
|
|
seen.add(value);
|
|
|
|
const record = value as Record<string, unknown>;
|
|
const keys = Object.keys(record).toSorted();
|
|
const visibleKeys = keys.slice(0, 8);
|
|
const extraCount = keys.length - visibleKeys.length;
|
|
const keySummary =
|
|
visibleKeys.length > 0
|
|
? `${visibleKeys.join(",")}${extraCount > 0 ? `,+${extraCount}` : ""}`
|
|
: "none";
|
|
const details = [`${label}:object keys=${keySummary}`];
|
|
|
|
for (const key of ["default", "module", "register", "activate"]) {
|
|
if (Object.prototype.hasOwnProperty.call(record, key)) {
|
|
details.push(...describePluginModuleExportShape(record[key], `${label}.${key}`, seen));
|
|
}
|
|
}
|
|
return details;
|
|
}
|
|
|
|
function formatMissingPluginRegisterError(moduleExport: unknown, env: NodeJS.ProcessEnv): string {
|
|
const message = "plugin export missing register/activate";
|
|
if (!isPluginLoadDebugEnabled(env)) {
|
|
return message;
|
|
}
|
|
return `${message} (module shape: ${describePluginModuleExportShape(moduleExport).join("; ")})`;
|
|
}
|
|
|
|
function mergeChannelPluginSection<T>(
|
|
baseValue: T | undefined,
|
|
overrideValue: T | undefined,
|
|
): T | undefined {
|
|
if (
|
|
baseValue &&
|
|
overrideValue &&
|
|
typeof baseValue === "object" &&
|
|
typeof overrideValue === "object"
|
|
) {
|
|
const merged = {
|
|
...(baseValue as Record<string, unknown>),
|
|
};
|
|
for (const [key, value] of Object.entries(overrideValue as Record<string, unknown>)) {
|
|
if (value !== undefined) {
|
|
merged[key] = value;
|
|
}
|
|
}
|
|
return {
|
|
...merged,
|
|
} as T;
|
|
}
|
|
return overrideValue ?? baseValue;
|
|
}
|
|
|
|
function mergeSetupRuntimeChannelPlugin(
|
|
runtimePlugin: ChannelPlugin,
|
|
setupPlugin: ChannelPlugin,
|
|
): ChannelPlugin {
|
|
return {
|
|
...runtimePlugin,
|
|
...setupPlugin,
|
|
meta: mergeChannelPluginSection(runtimePlugin.meta, setupPlugin.meta),
|
|
capabilities: mergeChannelPluginSection(runtimePlugin.capabilities, setupPlugin.capabilities),
|
|
commands: mergeChannelPluginSection(runtimePlugin.commands, setupPlugin.commands),
|
|
doctor: mergeChannelPluginSection(runtimePlugin.doctor, setupPlugin.doctor),
|
|
reload: mergeChannelPluginSection(runtimePlugin.reload, setupPlugin.reload),
|
|
config: mergeChannelPluginSection(runtimePlugin.config, setupPlugin.config),
|
|
setup: mergeChannelPluginSection(runtimePlugin.setup, setupPlugin.setup),
|
|
messaging: mergeChannelPluginSection(runtimePlugin.messaging, setupPlugin.messaging),
|
|
actions: mergeChannelPluginSection(runtimePlugin.actions, setupPlugin.actions),
|
|
secrets: mergeChannelPluginSection(runtimePlugin.secrets, setupPlugin.secrets),
|
|
} as ChannelPlugin;
|
|
}
|
|
|
|
function resolveBundledRuntimeChannelRegistration(moduleExport: unknown): {
|
|
id?: string;
|
|
loadChannelPlugin?: () => ChannelPlugin;
|
|
loadChannelSecrets?: () => ChannelPlugin["secrets"] | undefined;
|
|
setChannelRuntime?: (runtime: PluginRuntime) => void;
|
|
} {
|
|
const resolved = unwrapDefaultModuleExport(moduleExport);
|
|
if (!resolved || typeof resolved !== "object") {
|
|
return {};
|
|
}
|
|
const entryRecord = resolved as {
|
|
kind?: unknown;
|
|
id?: unknown;
|
|
loadChannelPlugin?: unknown;
|
|
loadChannelSecrets?: unknown;
|
|
setChannelRuntime?: unknown;
|
|
};
|
|
if (
|
|
entryRecord.kind !== "bundled-channel-entry" ||
|
|
typeof entryRecord.id !== "string" ||
|
|
typeof entryRecord.loadChannelPlugin !== "function"
|
|
) {
|
|
return {};
|
|
}
|
|
return {
|
|
id: entryRecord.id,
|
|
loadChannelPlugin: entryRecord.loadChannelPlugin as () => ChannelPlugin,
|
|
...(typeof entryRecord.loadChannelSecrets === "function"
|
|
? {
|
|
loadChannelSecrets: entryRecord.loadChannelSecrets as () =>
|
|
| ChannelPlugin["secrets"]
|
|
| undefined,
|
|
}
|
|
: {}),
|
|
...(typeof entryRecord.setChannelRuntime === "function"
|
|
? {
|
|
setChannelRuntime: entryRecord.setChannelRuntime as (runtime: PluginRuntime) => void,
|
|
}
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
function loadBundledRuntimeChannelPlugin(params: {
|
|
registration: ReturnType<typeof resolveBundledRuntimeChannelRegistration>;
|
|
}): {
|
|
plugin?: ChannelPlugin;
|
|
loadError?: unknown;
|
|
} {
|
|
if (typeof params.registration.loadChannelPlugin !== "function") {
|
|
return {};
|
|
}
|
|
try {
|
|
const loadedPlugin = params.registration.loadChannelPlugin();
|
|
const loadedSecrets = params.registration.loadChannelSecrets?.();
|
|
if (!loadedPlugin || typeof loadedPlugin !== "object") {
|
|
return {};
|
|
}
|
|
const mergedSecrets = mergeChannelPluginSection(loadedPlugin.secrets, loadedSecrets);
|
|
return {
|
|
plugin: {
|
|
...loadedPlugin,
|
|
...(mergedSecrets !== undefined ? { secrets: mergedSecrets } : {}),
|
|
},
|
|
};
|
|
} catch (err) {
|
|
return { loadError: err };
|
|
}
|
|
}
|
|
|
|
function resolveSetupChannelRegistration(
|
|
moduleExport: unknown,
|
|
params: { installRuntimeDeps?: boolean } = {},
|
|
): {
|
|
plugin?: ChannelPlugin;
|
|
setChannelRuntime?: (runtime: PluginRuntime) => void;
|
|
usesBundledSetupContract?: boolean;
|
|
loadError?: unknown;
|
|
} {
|
|
const resolved = unwrapDefaultModuleExport(moduleExport);
|
|
if (!resolved || typeof resolved !== "object") {
|
|
return {};
|
|
}
|
|
const setupEntryRecord = resolved as {
|
|
kind?: unknown;
|
|
loadSetupPlugin?: unknown;
|
|
loadSetupSecrets?: unknown;
|
|
setChannelRuntime?: unknown;
|
|
};
|
|
if (
|
|
setupEntryRecord.kind === "bundled-channel-setup-entry" &&
|
|
typeof setupEntryRecord.loadSetupPlugin === "function"
|
|
) {
|
|
try {
|
|
const setupLoadOptions =
|
|
params.installRuntimeDeps === false ? { installRuntimeDeps: false } : undefined;
|
|
const loadedPlugin = setupEntryRecord.loadSetupPlugin(setupLoadOptions);
|
|
const loadedSecrets =
|
|
typeof setupEntryRecord.loadSetupSecrets === "function"
|
|
? (setupEntryRecord.loadSetupSecrets(setupLoadOptions) as
|
|
| ChannelPlugin["secrets"]
|
|
| undefined)
|
|
: undefined;
|
|
if (loadedPlugin && typeof loadedPlugin === "object") {
|
|
const mergedSecrets = mergeChannelPluginSection(
|
|
(loadedPlugin as ChannelPlugin).secrets,
|
|
loadedSecrets,
|
|
);
|
|
return {
|
|
plugin: {
|
|
...(loadedPlugin as ChannelPlugin),
|
|
...(mergedSecrets !== undefined ? { secrets: mergedSecrets } : {}),
|
|
},
|
|
usesBundledSetupContract: true,
|
|
...(typeof setupEntryRecord.setChannelRuntime === "function"
|
|
? {
|
|
setChannelRuntime: setupEntryRecord.setChannelRuntime as (
|
|
runtime: PluginRuntime,
|
|
) => void,
|
|
}
|
|
: {}),
|
|
};
|
|
}
|
|
} catch (err) {
|
|
return { loadError: err };
|
|
}
|
|
}
|
|
const setup = resolved as {
|
|
plugin?: unknown;
|
|
};
|
|
if (!setup.plugin || typeof setup.plugin !== "object") {
|
|
return {};
|
|
}
|
|
return {
|
|
plugin: setup.plugin as ChannelPlugin,
|
|
};
|
|
}
|
|
|
|
function shouldLoadChannelPluginInSetupRuntime(params: {
|
|
manifestChannels: string[];
|
|
setupSource?: string;
|
|
startupDeferConfiguredChannelFullLoadUntilAfterListen?: boolean;
|
|
cfg: OpenClawConfig;
|
|
env: NodeJS.ProcessEnv;
|
|
preferSetupRuntimeForChannelPlugins?: boolean;
|
|
}): boolean {
|
|
if (!params.setupSource || params.manifestChannels.length === 0) {
|
|
return false;
|
|
}
|
|
if (
|
|
params.preferSetupRuntimeForChannelPlugins &&
|
|
params.startupDeferConfiguredChannelFullLoadUntilAfterListen === true
|
|
) {
|
|
return true;
|
|
}
|
|
return !params.manifestChannels.some((channelId) =>
|
|
isChannelConfigured(params.cfg, channelId, params.env),
|
|
);
|
|
}
|
|
|
|
function channelPluginIdBelongsToManifest(params: {
|
|
channelId: string | undefined;
|
|
pluginId: string;
|
|
manifestChannels: readonly string[];
|
|
}): boolean {
|
|
if (!params.channelId) {
|
|
return true;
|
|
}
|
|
return params.channelId === params.pluginId || params.manifestChannels.includes(params.channelId);
|
|
}
|
|
|
|
function createPluginRecord(params: {
|
|
id: string;
|
|
name?: string;
|
|
description?: string;
|
|
version?: string;
|
|
format?: PluginFormat;
|
|
bundleFormat?: PluginBundleFormat;
|
|
bundleCapabilities?: string[];
|
|
source: string;
|
|
rootDir?: string;
|
|
origin: PluginRecord["origin"];
|
|
workspaceDir?: string;
|
|
enabled: boolean;
|
|
activationState?: PluginActivationState;
|
|
configSchema: boolean;
|
|
contracts?: PluginManifestContracts;
|
|
}): PluginRecord {
|
|
return {
|
|
id: params.id,
|
|
name: params.name ?? params.id,
|
|
description: params.description,
|
|
version: params.version,
|
|
format: params.format ?? "openclaw",
|
|
bundleFormat: params.bundleFormat,
|
|
bundleCapabilities: params.bundleCapabilities,
|
|
source: params.source,
|
|
rootDir: params.rootDir,
|
|
origin: params.origin,
|
|
workspaceDir: params.workspaceDir,
|
|
enabled: params.enabled,
|
|
explicitlyEnabled: params.activationState?.explicitlyEnabled,
|
|
activated: params.activationState?.activated,
|
|
activationSource: params.activationState?.source,
|
|
activationReason: params.activationState?.reason,
|
|
status: params.enabled ? "loaded" : "disabled",
|
|
toolNames: [],
|
|
hookNames: [],
|
|
channelIds: [],
|
|
cliBackendIds: [],
|
|
providerIds: [],
|
|
speechProviderIds: [],
|
|
realtimeTranscriptionProviderIds: [],
|
|
realtimeVoiceProviderIds: [],
|
|
mediaUnderstandingProviderIds: [],
|
|
imageGenerationProviderIds: [],
|
|
videoGenerationProviderIds: [],
|
|
musicGenerationProviderIds: [],
|
|
webFetchProviderIds: [],
|
|
webSearchProviderIds: [],
|
|
contextEngineIds: [],
|
|
memoryEmbeddingProviderIds: [],
|
|
agentHarnessIds: [],
|
|
gatewayMethods: [],
|
|
cliCommands: [],
|
|
services: [],
|
|
gatewayDiscoveryServiceIds: [],
|
|
commands: [],
|
|
httpRoutes: 0,
|
|
hookCount: 0,
|
|
configSchema: params.configSchema,
|
|
configUiHints: undefined,
|
|
configJsonSchema: undefined,
|
|
contracts: params.contracts,
|
|
};
|
|
}
|
|
|
|
function markPluginActivationDisabled(record: PluginRecord, reason?: string): void {
|
|
record.activated = false;
|
|
record.activationSource = "disabled";
|
|
record.activationReason = reason;
|
|
}
|
|
|
|
function formatAutoEnabledActivationReason(
|
|
reasons: readonly string[] | undefined,
|
|
): string | undefined {
|
|
if (!reasons || reasons.length === 0) {
|
|
return undefined;
|
|
}
|
|
return reasons.join("; ");
|
|
}
|
|
|
|
function recordPluginError(params: {
|
|
logger: PluginLogger;
|
|
registry: PluginRegistry;
|
|
record: PluginRecord;
|
|
seenIds: Map<string, PluginRecord["origin"]>;
|
|
pluginId: string;
|
|
origin: PluginRecord["origin"];
|
|
phase: PluginRecord["failurePhase"];
|
|
error: unknown;
|
|
logPrefix: string;
|
|
diagnosticMessagePrefix: string;
|
|
}) {
|
|
const errorText =
|
|
process.env.OPENCLAW_PLUGIN_LOADER_DEBUG_STACKS === "1" &&
|
|
params.error instanceof Error &&
|
|
typeof params.error.stack === "string"
|
|
? params.error.stack
|
|
: String(params.error);
|
|
const deprecatedApiHint =
|
|
errorText.includes("api.registerHttpHandler") && errorText.includes("is not a function")
|
|
? "deprecated api.registerHttpHandler(...) was removed; use api.registerHttpRoute(...) for plugin-owned routes or registerPluginHttpRoute(...) for dynamic lifecycle routes"
|
|
: null;
|
|
const displayError = deprecatedApiHint ? `${deprecatedApiHint} (${errorText})` : errorText;
|
|
params.logger.error(`${params.logPrefix}${displayError}`);
|
|
params.record.status = "error";
|
|
params.record.error = displayError;
|
|
params.record.failedAt = new Date();
|
|
params.record.failurePhase = params.phase;
|
|
params.registry.plugins.push(params.record);
|
|
params.seenIds.set(params.pluginId, params.origin);
|
|
params.registry.diagnostics.push({
|
|
level: "error",
|
|
pluginId: params.record.id,
|
|
source: params.record.source,
|
|
message: `${params.diagnosticMessagePrefix}${displayError}`,
|
|
});
|
|
}
|
|
|
|
function formatPluginFailureSummary(failedPlugins: PluginRecord[]): string {
|
|
const grouped = new Map<NonNullable<PluginRecord["failurePhase"]>, string[]>();
|
|
for (const plugin of failedPlugins) {
|
|
const phase = plugin.failurePhase ?? "load";
|
|
const ids = grouped.get(phase);
|
|
if (ids) {
|
|
ids.push(plugin.id);
|
|
continue;
|
|
}
|
|
grouped.set(phase, [plugin.id]);
|
|
}
|
|
return [...grouped.entries()].map(([phase, ids]) => `${phase}: ${ids.join(", ")}`).join("; ");
|
|
}
|
|
|
|
function pushDiagnostics(diagnostics: PluginDiagnostic[], append: PluginDiagnostic[]) {
|
|
diagnostics.push(...append);
|
|
}
|
|
|
|
function maybeThrowOnPluginLoadError(
|
|
registry: PluginRegistry,
|
|
throwOnLoadError: boolean | undefined,
|
|
): void {
|
|
if (!throwOnLoadError) {
|
|
return;
|
|
}
|
|
if (!registry.plugins.some((entry) => entry.status === "error")) {
|
|
return;
|
|
}
|
|
throw new PluginLoadFailureError(registry);
|
|
}
|
|
|
|
type PathMatcher = {
|
|
exact: Set<string>;
|
|
dirs: string[];
|
|
};
|
|
|
|
type InstallTrackingRule = {
|
|
trackedWithoutPaths: boolean;
|
|
matcher: PathMatcher;
|
|
};
|
|
|
|
type PluginProvenanceIndex = {
|
|
loadPathMatcher: PathMatcher;
|
|
installRules: Map<string, InstallTrackingRule>;
|
|
};
|
|
|
|
function createPathMatcher(): PathMatcher {
|
|
return { exact: new Set<string>(), dirs: [] };
|
|
}
|
|
|
|
function addPathToMatcher(
|
|
matcher: PathMatcher,
|
|
rawPath: string,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): void {
|
|
const trimmed = rawPath.trim();
|
|
if (!trimmed) {
|
|
return;
|
|
}
|
|
const resolved = resolveUserPath(trimmed, env);
|
|
if (!resolved) {
|
|
return;
|
|
}
|
|
if (matcher.exact.has(resolved) || matcher.dirs.includes(resolved)) {
|
|
return;
|
|
}
|
|
const stat = safeStatSync(resolved);
|
|
if (stat?.isDirectory()) {
|
|
matcher.dirs.push(resolved);
|
|
return;
|
|
}
|
|
matcher.exact.add(resolved);
|
|
}
|
|
|
|
function matchesPathMatcher(matcher: PathMatcher, sourcePath: string): boolean {
|
|
if (matcher.exact.has(sourcePath)) {
|
|
return true;
|
|
}
|
|
return matcher.dirs.some((dirPath) => isPathInside(dirPath, sourcePath));
|
|
}
|
|
|
|
function buildProvenanceIndex(params: {
|
|
config: OpenClawConfig;
|
|
normalizedLoadPaths: string[];
|
|
env: NodeJS.ProcessEnv;
|
|
}): PluginProvenanceIndex {
|
|
const loadPathMatcher = createPathMatcher();
|
|
for (const loadPath of params.normalizedLoadPaths) {
|
|
addPathToMatcher(loadPathMatcher, loadPath, params.env);
|
|
}
|
|
|
|
const installRules = new Map<string, InstallTrackingRule>();
|
|
const installs = loadInstalledPluginIndexInstallRecordsSync({ env: params.env });
|
|
for (const [pluginId, install] of Object.entries(installs)) {
|
|
const rule: InstallTrackingRule = {
|
|
trackedWithoutPaths: false,
|
|
matcher: createPathMatcher(),
|
|
};
|
|
const trackedPaths = [install.installPath, install.sourcePath]
|
|
.map((entry) => normalizeOptionalString(entry))
|
|
.filter((entry): entry is string => Boolean(entry));
|
|
if (trackedPaths.length === 0) {
|
|
rule.trackedWithoutPaths = true;
|
|
} else {
|
|
for (const trackedPath of trackedPaths) {
|
|
addPathToMatcher(rule.matcher, trackedPath, params.env);
|
|
}
|
|
}
|
|
installRules.set(pluginId, rule);
|
|
}
|
|
|
|
return { loadPathMatcher, installRules };
|
|
}
|
|
|
|
function isTrackedByProvenance(params: {
|
|
pluginId: string;
|
|
source: string;
|
|
index: PluginProvenanceIndex;
|
|
env: NodeJS.ProcessEnv;
|
|
}): boolean {
|
|
const sourcePath = resolveUserPath(params.source, params.env);
|
|
const installRule = params.index.installRules.get(params.pluginId);
|
|
if (installRule) {
|
|
if (installRule.trackedWithoutPaths) {
|
|
return true;
|
|
}
|
|
if (matchesPathMatcher(installRule.matcher, sourcePath)) {
|
|
return true;
|
|
}
|
|
}
|
|
return matchesPathMatcher(params.index.loadPathMatcher, sourcePath);
|
|
}
|
|
|
|
function matchesExplicitInstallRule(params: {
|
|
pluginId: string;
|
|
source: string;
|
|
index: PluginProvenanceIndex;
|
|
env: NodeJS.ProcessEnv;
|
|
}): boolean {
|
|
const sourcePath = resolveUserPath(params.source, params.env);
|
|
const installRule = params.index.installRules.get(params.pluginId);
|
|
if (!installRule || installRule.trackedWithoutPaths) {
|
|
return false;
|
|
}
|
|
return matchesPathMatcher(installRule.matcher, sourcePath);
|
|
}
|
|
|
|
function resolveCandidateDuplicateRank(params: {
|
|
candidate: ReturnType<typeof discoverOpenClawPlugins>["candidates"][number];
|
|
manifestByRoot: Map<string, ReturnType<typeof loadPluginManifestRegistry>["plugins"][number]>;
|
|
provenance: PluginProvenanceIndex;
|
|
env: NodeJS.ProcessEnv;
|
|
}): number {
|
|
const manifestRecord = params.manifestByRoot.get(params.candidate.rootDir);
|
|
const pluginId = manifestRecord?.id;
|
|
const isExplicitInstall =
|
|
params.candidate.origin === "global" &&
|
|
pluginId !== undefined &&
|
|
matchesExplicitInstallRule({
|
|
pluginId,
|
|
source: params.candidate.source,
|
|
index: params.provenance,
|
|
env: params.env,
|
|
});
|
|
|
|
if (params.candidate.origin === "config") {
|
|
return 0;
|
|
}
|
|
if (params.candidate.origin === "global" && isExplicitInstall) {
|
|
return 1;
|
|
}
|
|
if (params.candidate.origin === "bundled") {
|
|
// Bundled plugin ids stay reserved unless the operator configured an override.
|
|
return 2;
|
|
}
|
|
if (params.candidate.origin === "workspace") {
|
|
return 3;
|
|
}
|
|
return 4;
|
|
}
|
|
|
|
function compareDuplicateCandidateOrder(params: {
|
|
left: ReturnType<typeof discoverOpenClawPlugins>["candidates"][number];
|
|
right: ReturnType<typeof discoverOpenClawPlugins>["candidates"][number];
|
|
manifestByRoot: Map<string, ReturnType<typeof loadPluginManifestRegistry>["plugins"][number]>;
|
|
provenance: PluginProvenanceIndex;
|
|
env: NodeJS.ProcessEnv;
|
|
}): number {
|
|
const leftPluginId = params.manifestByRoot.get(params.left.rootDir)?.id;
|
|
const rightPluginId = params.manifestByRoot.get(params.right.rootDir)?.id;
|
|
if (!leftPluginId || leftPluginId !== rightPluginId) {
|
|
return 0;
|
|
}
|
|
return (
|
|
resolveCandidateDuplicateRank({
|
|
candidate: params.left,
|
|
manifestByRoot: params.manifestByRoot,
|
|
provenance: params.provenance,
|
|
env: params.env,
|
|
}) -
|
|
resolveCandidateDuplicateRank({
|
|
candidate: params.right,
|
|
manifestByRoot: params.manifestByRoot,
|
|
provenance: params.provenance,
|
|
env: params.env,
|
|
})
|
|
);
|
|
}
|
|
|
|
function warnWhenAllowlistIsOpen(params: {
|
|
emitWarning: boolean;
|
|
logger: PluginLogger;
|
|
pluginsEnabled: boolean;
|
|
allow: string[];
|
|
warningCacheKey: string;
|
|
discoverablePlugins: Array<{ id: string; source: string; origin: PluginRecord["origin"] }>;
|
|
}) {
|
|
if (!params.emitWarning) {
|
|
return;
|
|
}
|
|
if (!params.pluginsEnabled) {
|
|
return;
|
|
}
|
|
if (params.allow.length > 0) {
|
|
return;
|
|
}
|
|
const autoDiscoverable = params.discoverablePlugins.filter(
|
|
(entry) => entry.origin === "workspace" || entry.origin === "global",
|
|
);
|
|
if (autoDiscoverable.length === 0) {
|
|
return;
|
|
}
|
|
if (openAllowlistWarningCache.has(params.warningCacheKey)) {
|
|
return;
|
|
}
|
|
const preview = autoDiscoverable
|
|
.slice(0, 6)
|
|
.map((entry) => `${entry.id} (${entry.source})`)
|
|
.join(", ");
|
|
const extra = autoDiscoverable.length > 6 ? ` (+${autoDiscoverable.length - 6} more)` : "";
|
|
openAllowlistWarningCache.add(params.warningCacheKey);
|
|
params.logger.warn(
|
|
`[plugins] plugins.allow is empty; discovered non-bundled plugins may auto-load: ${preview}${extra}. Set plugins.allow to explicit trusted ids.`,
|
|
);
|
|
}
|
|
|
|
function warnAboutUntrackedLoadedPlugins(params: {
|
|
registry: PluginRegistry;
|
|
provenance: PluginProvenanceIndex;
|
|
allowlist: string[];
|
|
emitWarning: boolean;
|
|
logger: PluginLogger;
|
|
env: NodeJS.ProcessEnv;
|
|
}) {
|
|
const allowSet = new Set(params.allowlist);
|
|
for (const plugin of params.registry.plugins) {
|
|
if (plugin.status !== "loaded" || plugin.origin === "bundled") {
|
|
continue;
|
|
}
|
|
if (allowSet.has(plugin.id)) {
|
|
continue;
|
|
}
|
|
if (
|
|
isTrackedByProvenance({
|
|
pluginId: plugin.id,
|
|
source: plugin.source,
|
|
index: params.provenance,
|
|
env: params.env,
|
|
})
|
|
) {
|
|
continue;
|
|
}
|
|
const message =
|
|
"loaded without install/load-path provenance; treat as untracked local code and pin trust via plugins.allow or install records";
|
|
params.registry.diagnostics.push({
|
|
level: "warn",
|
|
pluginId: plugin.id,
|
|
source: plugin.source,
|
|
message,
|
|
});
|
|
if (params.emitWarning) {
|
|
params.logger.warn(`[plugins] ${plugin.id}: ${message} (${plugin.source})`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function activatePluginRegistry(
|
|
registry: PluginRegistry,
|
|
cacheKey: string,
|
|
runtimeSubagentMode: "default" | "explicit" | "gateway-bindable",
|
|
workspaceDir?: string,
|
|
): void {
|
|
const preserveGatewayHookRunner =
|
|
runtimeSubagentMode === "default" &&
|
|
getActivePluginRuntimeSubagentMode() === "gateway-bindable" &&
|
|
getGlobalHookRunner() !== null;
|
|
setActivePluginRegistry(registry, cacheKey, runtimeSubagentMode, workspaceDir);
|
|
if (!preserveGatewayHookRunner) {
|
|
initializeGlobalHookRunner(registry);
|
|
}
|
|
}
|
|
|
|
export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegistry {
|
|
const {
|
|
env,
|
|
cfg,
|
|
normalized,
|
|
activationSource,
|
|
autoEnabledReasons,
|
|
onlyPluginIds,
|
|
includeSetupOnlyChannelPlugins,
|
|
forceSetupOnlyChannelPlugins,
|
|
requireSetupEntryForSetupOnlyChannelPlugins,
|
|
preferSetupRuntimeForChannelPlugins,
|
|
shouldActivate,
|
|
shouldLoadModules,
|
|
shouldInstallBundledRuntimeDeps,
|
|
cacheKey,
|
|
runtimeSubagentMode,
|
|
installRecords,
|
|
} = resolvePluginLoadCacheContext(options);
|
|
const logger = options.logger ?? defaultLogger();
|
|
const validateOnly = options.mode === "validate";
|
|
const onlyPluginIdSet = createPluginIdScopeSet(onlyPluginIds);
|
|
const cacheEnabled = options.cache !== false;
|
|
if (cacheEnabled) {
|
|
const cached = getCachedPluginRegistry(cacheKey);
|
|
if (cached) {
|
|
if (shouldActivate) {
|
|
restoreRegisteredAgentHarnesses(cached.agentHarnesses);
|
|
restorePluginCommands(cached.commands ?? []);
|
|
restoreRegisteredCompactionProviders(cached.compactionProviders);
|
|
restoreDetachedTaskLifecycleRuntimeRegistration(cached.detachedTaskRuntimeRegistration);
|
|
restorePluginInteractiveHandlers(cached.interactiveHandlers ?? []);
|
|
restoreRegisteredMemoryEmbeddingProviders(cached.memoryEmbeddingProviders);
|
|
restoreMemoryPluginState({
|
|
capability: cached.memoryCapability,
|
|
corpusSupplements: cached.memoryCorpusSupplements,
|
|
promptBuilder: cached.memoryPromptBuilder,
|
|
promptSupplements: cached.memoryPromptSupplements,
|
|
flushPlanResolver: cached.memoryFlushPlanResolver,
|
|
runtime: cached.memoryRuntime,
|
|
});
|
|
activatePluginRegistry(
|
|
cached.registry,
|
|
cacheKey,
|
|
runtimeSubagentMode,
|
|
options.workspaceDir,
|
|
);
|
|
}
|
|
return cached.registry;
|
|
}
|
|
}
|
|
if (inFlightPluginRegistryLoads.has(cacheKey)) {
|
|
throw new PluginLoadReentryError(cacheKey);
|
|
}
|
|
inFlightPluginRegistryLoads.add(cacheKey);
|
|
try {
|
|
// Clear previously registered plugin state before reloading.
|
|
// Skip for non-activating (snapshot) loads to avoid wiping commands from other plugins.
|
|
if (shouldActivate) {
|
|
clearAgentHarnesses();
|
|
clearPluginCommands();
|
|
clearPluginInteractiveHandlers();
|
|
clearDetachedTaskLifecycleRuntimeRegistration();
|
|
clearMemoryPluginState();
|
|
}
|
|
|
|
// Lazy: avoid creating the Jiti loader when all plugins are disabled (common in unit tests).
|
|
const getJiti = createPluginJitiLoader(options);
|
|
|
|
let createPluginRuntimeFactory:
|
|
| ((options?: CreatePluginRuntimeOptions) => PluginRuntime)
|
|
| null = null;
|
|
const resolveCreatePluginRuntime = (): ((
|
|
options?: CreatePluginRuntimeOptions,
|
|
) => PluginRuntime) => {
|
|
if (createPluginRuntimeFactory) {
|
|
return createPluginRuntimeFactory;
|
|
}
|
|
const runtimeModulePath = resolvePluginRuntimeModulePath({
|
|
pluginSdkResolution: options.pluginSdkResolution,
|
|
});
|
|
if (!runtimeModulePath) {
|
|
throw new Error("Unable to resolve plugin runtime module");
|
|
}
|
|
const safeRuntimePath = toSafeImportPath(runtimeModulePath);
|
|
const runtimeModule = withProfile(
|
|
{ source: runtimeModulePath },
|
|
"runtime-module",
|
|
() =>
|
|
getJiti(runtimeModulePath)(safeRuntimePath) as {
|
|
createPluginRuntime?: (options?: CreatePluginRuntimeOptions) => PluginRuntime;
|
|
},
|
|
);
|
|
if (typeof runtimeModule.createPluginRuntime !== "function") {
|
|
throw new Error("Plugin runtime module missing createPluginRuntime export");
|
|
}
|
|
createPluginRuntimeFactory = runtimeModule.createPluginRuntime;
|
|
return createPluginRuntimeFactory;
|
|
};
|
|
|
|
// Lazily initialize the runtime so startup paths that discover/skip plugins do
|
|
// not eagerly load every channel/runtime dependency tree.
|
|
let resolvedRuntime: PluginRuntime | null = null;
|
|
const resolveRuntime = (): PluginRuntime => {
|
|
resolvedRuntime ??= resolveCreatePluginRuntime()(options.runtimeOptions);
|
|
return resolvedRuntime;
|
|
};
|
|
const lazyRuntimeReflectionKeySet = new Set<PropertyKey>(LAZY_RUNTIME_REFLECTION_KEYS);
|
|
const resolveLazyRuntimeDescriptor = (prop: PropertyKey): PropertyDescriptor | undefined => {
|
|
if (!lazyRuntimeReflectionKeySet.has(prop)) {
|
|
return Reflect.getOwnPropertyDescriptor(resolveRuntime() as object, prop);
|
|
}
|
|
return {
|
|
configurable: true,
|
|
enumerable: true,
|
|
get() {
|
|
return Reflect.get(resolveRuntime() as object, prop);
|
|
},
|
|
set(value: unknown) {
|
|
Reflect.set(resolveRuntime() as object, prop, value);
|
|
},
|
|
};
|
|
};
|
|
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 lazyRuntimeReflectionKeySet.has(prop) || Reflect.has(resolveRuntime(), prop);
|
|
},
|
|
ownKeys() {
|
|
return [...LAZY_RUNTIME_REFLECTION_KEYS];
|
|
},
|
|
getOwnPropertyDescriptor(_target, prop) {
|
|
return resolveLazyRuntimeDescriptor(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,
|
|
rollbackPluginGlobalSideEffects,
|
|
registerReload,
|
|
registerNodeHostCommand,
|
|
registerSecurityAuditCollector,
|
|
} = createPluginRegistry({
|
|
logger,
|
|
runtime,
|
|
coreGatewayHandlers: options.coreGatewayHandlers as Record<string, GatewayRequestHandler>,
|
|
activateGlobalSideEffects: shouldActivate,
|
|
});
|
|
|
|
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,
|
|
installRecords: Object.keys(installRecords).length > 0 ? installRecords : undefined,
|
|
});
|
|
pushDiagnostics(registry.diagnostics, manifestRegistry.diagnostics);
|
|
warnWhenAllowlistIsOpen({
|
|
emitWarning: shouldActivate,
|
|
logger,
|
|
pluginsEnabled: normalized.enabled,
|
|
allow: normalized.allow,
|
|
warningCacheKey: cacheKey,
|
|
// Keep warning input scoped as well so partial snapshot loads only mention the
|
|
// plugins that were intentionally requested for this registry.
|
|
discoverablePlugins: manifestRegistry.plugins
|
|
.filter((plugin) => !onlyPluginIdSet || onlyPluginIdSet.has(plugin.id))
|
|
.map((plugin) => ({
|
|
id: plugin.id,
|
|
source: plugin.source,
|
|
origin: plugin.origin,
|
|
})),
|
|
});
|
|
const provenance = buildProvenanceIndex({
|
|
config: cfg,
|
|
normalizedLoadPaths: normalized.loadPaths,
|
|
env,
|
|
});
|
|
|
|
const manifestByRoot = new Map(
|
|
manifestRegistry.plugins.map((record) => [record.rootDir, record]),
|
|
);
|
|
const orderedCandidates = [...discovery.candidates].toSorted((left, right) => {
|
|
return compareDuplicateCandidateOrder({
|
|
left,
|
|
right,
|
|
manifestByRoot,
|
|
provenance,
|
|
env,
|
|
});
|
|
});
|
|
|
|
const seenIds = new Map<string, PluginRecord["origin"]>();
|
|
const bundledRuntimeDepsRetainSpecsByInstallRoot = new Map<string, string[]>();
|
|
const memorySlot = normalized.slots.memory;
|
|
let selectedMemoryPluginId: string | null = null;
|
|
let memorySlotMatched = false;
|
|
const dreamingEngineId = resolveDreamingSidecarEngineId({ cfg, memorySlot });
|
|
|
|
for (const candidate of orderedCandidates) {
|
|
const manifestRecord = manifestByRoot.get(candidate.rootDir);
|
|
if (!manifestRecord) {
|
|
continue;
|
|
}
|
|
const pluginId = manifestRecord.id;
|
|
const matchesRequestedScope = matchesScopedPluginRequest({
|
|
onlyPluginIdSet,
|
|
pluginId,
|
|
});
|
|
// Filter again at import time as a final guard. The earlier manifest filter keeps
|
|
// warnings scoped; this one prevents loading/registering anything outside the scope.
|
|
if (!matchesRequestedScope) {
|
|
continue;
|
|
}
|
|
const activationState = resolveEffectivePluginActivationState({
|
|
id: pluginId,
|
|
origin: candidate.origin,
|
|
config: normalized,
|
|
rootConfig: cfg,
|
|
enabledByDefault: manifestRecord.enabledByDefault,
|
|
activationSource,
|
|
autoEnabledReason: formatAutoEnabledActivationReason(autoEnabledReasons[pluginId]),
|
|
});
|
|
const existingOrigin = seenIds.get(pluginId);
|
|
if (existingOrigin) {
|
|
const record = createPluginRecord({
|
|
id: pluginId,
|
|
name: manifestRecord.name ?? pluginId,
|
|
description: manifestRecord.description,
|
|
version: manifestRecord.version,
|
|
format: manifestRecord.format,
|
|
bundleFormat: manifestRecord.bundleFormat,
|
|
bundleCapabilities: manifestRecord.bundleCapabilities,
|
|
source: candidate.source,
|
|
rootDir: candidate.rootDir,
|
|
origin: candidate.origin,
|
|
workspaceDir: candidate.workspaceDir,
|
|
enabled: false,
|
|
activationState,
|
|
configSchema: Boolean(manifestRecord.configSchema),
|
|
contracts: manifestRecord.contracts,
|
|
});
|
|
record.status = "disabled";
|
|
record.error = `overridden by ${existingOrigin} plugin`;
|
|
markPluginActivationDisabled(record, record.error);
|
|
registry.plugins.push(record);
|
|
continue;
|
|
}
|
|
|
|
const enableState = resolveEffectiveEnableState({
|
|
id: pluginId,
|
|
origin: candidate.origin,
|
|
config: normalized,
|
|
rootConfig: cfg,
|
|
enabledByDefault: manifestRecord.enabledByDefault,
|
|
activationSource,
|
|
});
|
|
const entry = normalized.entries[pluginId];
|
|
const record = createPluginRecord({
|
|
id: pluginId,
|
|
name: manifestRecord.name ?? pluginId,
|
|
description: manifestRecord.description,
|
|
version: manifestRecord.version,
|
|
format: manifestRecord.format,
|
|
bundleFormat: manifestRecord.bundleFormat,
|
|
bundleCapabilities: manifestRecord.bundleCapabilities,
|
|
source: candidate.source,
|
|
rootDir: candidate.rootDir,
|
|
origin: candidate.origin,
|
|
workspaceDir: candidate.workspaceDir,
|
|
enabled: enableState.enabled,
|
|
activationState,
|
|
configSchema: Boolean(manifestRecord.configSchema),
|
|
contracts: manifestRecord.contracts,
|
|
});
|
|
record.kind = manifestRecord.kind;
|
|
record.configUiHints = manifestRecord.configUiHints;
|
|
record.configJsonSchema = manifestRecord.configSchema;
|
|
const pushPluginLoadError = (message: string) => {
|
|
record.status = "error";
|
|
record.error = message;
|
|
record.failedAt = new Date();
|
|
record.failurePhase = "validation";
|
|
registry.plugins.push(record);
|
|
seenIds.set(pluginId, candidate.origin);
|
|
registry.diagnostics.push({
|
|
level: "error",
|
|
pluginId: record.id,
|
|
source: record.source,
|
|
message: record.error,
|
|
});
|
|
};
|
|
const pluginRoot = safeRealpathOrResolve(candidate.rootDir);
|
|
let runtimePluginRoot = pluginRoot;
|
|
let runtimeCandidateSource =
|
|
candidate.origin === "bundled" ? safeRealpathOrResolve(candidate.source) : candidate.source;
|
|
let runtimeSetupSource =
|
|
candidate.origin === "bundled" && manifestRecord.setupSource
|
|
? safeRealpathOrResolve(manifestRecord.setupSource)
|
|
: manifestRecord.setupSource;
|
|
|
|
const scopedSetupOnlyChannelPluginRequested =
|
|
includeSetupOnlyChannelPlugins &&
|
|
!validateOnly &&
|
|
Boolean(onlyPluginIdSet) &&
|
|
manifestRecord.channels.length > 0 &&
|
|
(!enableState.enabled || forceSetupOnlyChannelPlugins);
|
|
const canLoadScopedSetupOnlyChannelPlugin =
|
|
scopedSetupOnlyChannelPluginRequested &&
|
|
(!requireSetupEntryForSetupOnlyChannelPlugins || Boolean(manifestRecord.setupSource));
|
|
const registrationPlan = resolvePluginRegistrationPlan({
|
|
canLoadScopedSetupOnlyChannelPlugin,
|
|
scopedSetupOnlyChannelPluginRequested,
|
|
requireSetupEntryForSetupOnlyChannelPlugins,
|
|
enableStateEnabled: enableState.enabled,
|
|
shouldLoadModules,
|
|
validateOnly,
|
|
shouldActivate,
|
|
manifestRecord,
|
|
cfg,
|
|
env,
|
|
preferSetupRuntimeForChannelPlugins,
|
|
});
|
|
|
|
if (!registrationPlan) {
|
|
record.status = "disabled";
|
|
record.error = enableState.reason;
|
|
markPluginActivationDisabled(record, enableState.reason);
|
|
registry.plugins.push(record);
|
|
seenIds.set(pluginId, candidate.origin);
|
|
continue;
|
|
}
|
|
const registrationMode = registrationPlan.mode;
|
|
if (!enableState.enabled) {
|
|
record.status = "disabled";
|
|
record.error = enableState.reason;
|
|
markPluginActivationDisabled(record, enableState.reason);
|
|
}
|
|
|
|
if (
|
|
shouldLoadModules &&
|
|
shouldInstallBundledRuntimeDeps &&
|
|
candidate.origin === "bundled" &&
|
|
enableState.enabled
|
|
) {
|
|
let runtimeDepsInstallStartedAt: number | null = null;
|
|
let runtimeDepsInstallSpecs: string[] = [];
|
|
try {
|
|
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env });
|
|
const retainSpecs = bundledRuntimeDepsRetainSpecsByInstallRoot.get(installRoot) ?? [];
|
|
const depsInstallResult = ensureBundledPluginRuntimeDeps({
|
|
pluginId: record.id,
|
|
pluginRoot,
|
|
env,
|
|
config: cfg,
|
|
retainSpecs,
|
|
installDeps: (installParams) => {
|
|
const installSpecs = installParams.installSpecs ?? installParams.missingSpecs;
|
|
runtimeDepsInstallStartedAt = Date.now();
|
|
runtimeDepsInstallSpecs = installParams.missingSpecs;
|
|
if (shouldActivate) {
|
|
logger.info(
|
|
`[plugins] ${record.id} staging bundled runtime deps (${installParams.missingSpecs.length} missing, ${installSpecs.length} install specs): ${installParams.missingSpecs.join(", ")}`,
|
|
);
|
|
}
|
|
const installer =
|
|
options.bundledRuntimeDepsInstaller ??
|
|
((params: BundledRuntimeDepsInstallParams) =>
|
|
installBundledRuntimeDeps({
|
|
installRoot: params.installRoot,
|
|
installExecutionRoot: params.installExecutionRoot,
|
|
missingSpecs: params.installSpecs ?? params.missingSpecs,
|
|
env,
|
|
warn: (message) => logger.warn(`[plugins] ${record.id}: ${message}`),
|
|
}));
|
|
installer(installParams);
|
|
},
|
|
});
|
|
if (depsInstallResult.installedSpecs.length > 0) {
|
|
bundledRuntimeDepsRetainSpecsByInstallRoot.set(
|
|
installRoot,
|
|
[...new Set([...retainSpecs, ...depsInstallResult.retainSpecs])].toSorted(
|
|
(left, right) => left.localeCompare(right),
|
|
),
|
|
);
|
|
if (shouldActivate) {
|
|
const elapsed =
|
|
runtimeDepsInstallStartedAt === null
|
|
? ""
|
|
: ` in ${Date.now() - runtimeDepsInstallStartedAt}ms`;
|
|
logger.info(
|
|
`[plugins] ${record.id} installed bundled runtime deps${elapsed}: ${depsInstallResult.installedSpecs.join(", ")}`,
|
|
);
|
|
}
|
|
}
|
|
if (path.resolve(installRoot) !== path.resolve(pluginRoot)) {
|
|
const packageRoot = resolveBundledRuntimeDependencyPackageRoot(pluginRoot);
|
|
if (packageRoot) {
|
|
registerBundledRuntimeDependencyNodePath(packageRoot);
|
|
registerBundledRuntimeDependencyJitiAliases(packageRoot);
|
|
}
|
|
registerBundledRuntimeDependencyNodePath(installRoot);
|
|
registerBundledRuntimeDependencyJitiAliases(installRoot);
|
|
runtimePluginRoot = mirrorBundledPluginRuntimeRoot({
|
|
pluginId: record.id,
|
|
pluginRoot,
|
|
installRoot,
|
|
});
|
|
runtimeCandidateSource =
|
|
remapBundledPluginRuntimePath({
|
|
source: runtimeCandidateSource,
|
|
pluginRoot,
|
|
mirroredRoot: runtimePluginRoot,
|
|
}) ?? runtimeCandidateSource;
|
|
runtimeSetupSource = remapBundledPluginRuntimePath({
|
|
source: runtimeSetupSource,
|
|
pluginRoot,
|
|
mirroredRoot: runtimePluginRoot,
|
|
});
|
|
} else {
|
|
ensureOpenClawPluginSdkAlias(path.dirname(path.dirname(pluginRoot)));
|
|
}
|
|
} catch (error) {
|
|
if (shouldActivate && runtimeDepsInstallStartedAt !== null) {
|
|
logger.error(
|
|
`[plugins] ${record.id} failed to stage bundled runtime deps after ${Date.now() - runtimeDepsInstallStartedAt}ms: ${runtimeDepsInstallSpecs.join(", ")}`,
|
|
);
|
|
}
|
|
pushPluginLoadError(`failed to install bundled runtime deps: ${String(error)}`);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (record.format === "bundle") {
|
|
const unsupportedCapabilities = (record.bundleCapabilities ?? []).filter(
|
|
(capability) =>
|
|
capability !== "skills" &&
|
|
capability !== "mcpServers" &&
|
|
capability !== "settings" &&
|
|
!(
|
|
(capability === "commands" ||
|
|
capability === "agents" ||
|
|
capability === "outputStyles" ||
|
|
capability === "lspServers") &&
|
|
(record.bundleFormat === "claude" || record.bundleFormat === "cursor")
|
|
) &&
|
|
!(
|
|
capability === "hooks" &&
|
|
(record.bundleFormat === "codex" || record.bundleFormat === "claude")
|
|
),
|
|
);
|
|
for (const capability of unsupportedCapabilities) {
|
|
registry.diagnostics.push({
|
|
level: "warn",
|
|
pluginId: record.id,
|
|
source: record.source,
|
|
message: `bundle capability detected but not wired into OpenClaw yet: ${capability}`,
|
|
});
|
|
}
|
|
if (
|
|
enableState.enabled &&
|
|
record.rootDir &&
|
|
record.bundleFormat &&
|
|
(record.bundleCapabilities ?? []).includes("mcpServers")
|
|
) {
|
|
const runtimeSupport = inspectBundleMcpRuntimeSupport({
|
|
pluginId: record.id,
|
|
rootDir: record.rootDir,
|
|
bundleFormat: record.bundleFormat,
|
|
});
|
|
for (const message of runtimeSupport.diagnostics) {
|
|
registry.diagnostics.push({
|
|
level: "warn",
|
|
pluginId: record.id,
|
|
source: record.source,
|
|
message,
|
|
});
|
|
}
|
|
if (runtimeSupport.unsupportedServerNames.length > 0) {
|
|
registry.diagnostics.push({
|
|
level: "warn",
|
|
pluginId: record.id,
|
|
source: record.source,
|
|
message:
|
|
"bundle MCP servers use unsupported transports or incomplete configs " +
|
|
`(stdio only today): ${runtimeSupport.unsupportedServerNames.join(", ")}`,
|
|
});
|
|
}
|
|
}
|
|
registry.plugins.push(record);
|
|
seenIds.set(pluginId, 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.
|
|
// Exception: the dreaming engine (memory-core by default) must load alongside the
|
|
// selected memory slot plugin so dreaming can run even when lancedb holds the slot.
|
|
if (
|
|
registrationPlan.runRuntimeCapabilityPolicy &&
|
|
candidate.origin === "bundled" &&
|
|
hasKind(manifestRecord.kind, "memory")
|
|
) {
|
|
if (pluginId !== dreamingEngineId) {
|
|
const earlyMemoryDecision = resolveMemorySlotDecision({
|
|
id: record.id,
|
|
kind: manifestRecord.kind,
|
|
slot: memorySlot,
|
|
selectedId: selectedMemoryPluginId,
|
|
});
|
|
if (!earlyMemoryDecision.enabled) {
|
|
record.enabled = false;
|
|
record.status = "disabled";
|
|
record.error = earlyMemoryDecision.reason;
|
|
markPluginActivationDisabled(record, earlyMemoryDecision.reason);
|
|
registry.plugins.push(record);
|
|
seenIds.set(pluginId, candidate.origin);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!manifestRecord.configSchema) {
|
|
pushPluginLoadError("missing config schema");
|
|
continue;
|
|
}
|
|
|
|
if (!shouldLoadModules && registrationPlan.runRuntimeCapabilityPolicy) {
|
|
const memoryDecision = resolveMemorySlotDecision({
|
|
id: record.id,
|
|
kind: record.kind,
|
|
slot: memorySlot,
|
|
selectedId: selectedMemoryPluginId,
|
|
});
|
|
|
|
if (!memoryDecision.enabled && pluginId !== dreamingEngineId) {
|
|
record.enabled = false;
|
|
record.status = "disabled";
|
|
record.error = memoryDecision.reason;
|
|
markPluginActivationDisabled(record, memoryDecision.reason);
|
|
registry.plugins.push(record);
|
|
seenIds.set(pluginId, candidate.origin);
|
|
continue;
|
|
}
|
|
|
|
if (memoryDecision.selected && hasKind(record.kind, "memory")) {
|
|
selectedMemoryPluginId = record.id;
|
|
memorySlotMatched = true;
|
|
record.memorySlotSelected = true;
|
|
}
|
|
}
|
|
|
|
const validatedConfig = validatePluginConfig({
|
|
schema: manifestRecord.configSchema,
|
|
cacheKey: manifestRecord.schemaCacheKey,
|
|
value: entry?.config,
|
|
});
|
|
|
|
if (!validatedConfig.ok) {
|
|
logger.error(
|
|
`[plugins] ${record.id} invalid config: ${validatedConfig.errors?.join(", ")}`,
|
|
);
|
|
pushPluginLoadError(`invalid config: ${validatedConfig.errors?.join(", ")}`);
|
|
continue;
|
|
}
|
|
|
|
if (!shouldLoadModules) {
|
|
registry.plugins.push(record);
|
|
seenIds.set(pluginId, candidate.origin);
|
|
continue;
|
|
}
|
|
|
|
const loadSource =
|
|
registrationPlan.loadSetupEntry && runtimeSetupSource
|
|
? runtimeSetupSource
|
|
: runtimeCandidateSource;
|
|
const moduleLoadSource = resolveCanonicalDistRuntimeSource(loadSource);
|
|
const moduleRoot = resolveCanonicalDistRuntimeSource(runtimePluginRoot);
|
|
const opened = openBoundaryFileSync({
|
|
absolutePath: moduleLoadSource,
|
|
rootPath: moduleRoot,
|
|
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);
|
|
const safeImportSource = toSafeImportPath(safeSource);
|
|
|
|
let mod: OpenClawPluginModule | null = null;
|
|
try {
|
|
// Track the plugin as imported once module evaluation begins. Top-level
|
|
// code may have already executed even if evaluation later throws.
|
|
recordImportedPluginId(record.id);
|
|
mod = withProfile(
|
|
{ pluginId: record.id, source: safeSource },
|
|
registrationMode,
|
|
() => getJiti(safeSource)(safeImportSource) as OpenClawPluginModule,
|
|
);
|
|
} catch (err) {
|
|
recordPluginError({
|
|
logger,
|
|
registry,
|
|
record,
|
|
seenIds,
|
|
pluginId,
|
|
origin: candidate.origin,
|
|
phase: "load",
|
|
error: err,
|
|
logPrefix: `[plugins] ${record.id} failed to load from ${record.source}: `,
|
|
diagnosticMessagePrefix: "failed to load plugin: ",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (registrationPlan.loadSetupEntry && manifestRecord.setupSource) {
|
|
const setupRegistration = resolveSetupChannelRegistration(mod, {
|
|
installRuntimeDeps:
|
|
shouldInstallBundledRuntimeDeps &&
|
|
(enableState.enabled || forceSetupOnlyChannelPlugins),
|
|
});
|
|
if (setupRegistration.loadError) {
|
|
recordPluginError({
|
|
logger,
|
|
registry,
|
|
record,
|
|
seenIds,
|
|
pluginId,
|
|
origin: candidate.origin,
|
|
phase: "load",
|
|
error: setupRegistration.loadError,
|
|
logPrefix: `[plugins] ${record.id} failed to load setup entry from ${record.source}: `,
|
|
diagnosticMessagePrefix: "failed to load setup entry: ",
|
|
});
|
|
continue;
|
|
}
|
|
if (setupRegistration.plugin) {
|
|
if (
|
|
!channelPluginIdBelongsToManifest({
|
|
channelId: setupRegistration.plugin.id,
|
|
pluginId: record.id,
|
|
manifestChannels: manifestRecord.channels,
|
|
})
|
|
) {
|
|
pushPluginLoadError(
|
|
`plugin id mismatch (config uses "${record.id}", setup export uses "${setupRegistration.plugin.id}")`,
|
|
);
|
|
continue;
|
|
}
|
|
const api = createApi(record, {
|
|
config: cfg,
|
|
pluginConfig: {},
|
|
hookPolicy: entry?.hooks,
|
|
registrationMode,
|
|
});
|
|
let mergedSetupRegistration = setupRegistration;
|
|
let runtimeSetterApplied = false;
|
|
if (
|
|
registrationPlan.loadSetupRuntimeEntry &&
|
|
setupRegistration.usesBundledSetupContract &&
|
|
runtimeCandidateSource !== safeSource
|
|
) {
|
|
const runtimeOpened = openBoundaryFileSync({
|
|
absolutePath: runtimeCandidateSource,
|
|
rootPath: runtimePluginRoot,
|
|
boundaryLabel: "plugin root",
|
|
rejectHardlinks: candidate.origin !== "bundled",
|
|
skipLexicalRootCheck: true,
|
|
});
|
|
if (!runtimeOpened.ok) {
|
|
pushPluginLoadError("plugin entry path escapes plugin root or fails alias checks");
|
|
continue;
|
|
}
|
|
const safeRuntimeSource = runtimeOpened.path;
|
|
fs.closeSync(runtimeOpened.fd);
|
|
const safeRuntimeImportSource = toSafeImportPath(safeRuntimeSource);
|
|
let runtimeMod: OpenClawPluginModule | null = null;
|
|
try {
|
|
runtimeMod = withProfile(
|
|
{ pluginId: record.id, source: safeRuntimeSource },
|
|
"load-setup-runtime-entry",
|
|
() => getJiti(safeRuntimeSource)(safeRuntimeImportSource) as OpenClawPluginModule,
|
|
);
|
|
} catch (err) {
|
|
recordPluginError({
|
|
logger,
|
|
registry,
|
|
record,
|
|
seenIds,
|
|
pluginId,
|
|
origin: candidate.origin,
|
|
phase: "load",
|
|
error: err,
|
|
logPrefix: `[plugins] ${record.id} failed to load setup-runtime entry from ${record.source}: `,
|
|
diagnosticMessagePrefix: "failed to load setup-runtime entry: ",
|
|
});
|
|
continue;
|
|
}
|
|
const runtimeRegistration = resolveBundledRuntimeChannelRegistration(runtimeMod);
|
|
if (runtimeRegistration.id && runtimeRegistration.id !== record.id) {
|
|
pushPluginLoadError(
|
|
`plugin id mismatch (config uses "${record.id}", runtime entry uses "${runtimeRegistration.id}")`,
|
|
);
|
|
continue;
|
|
}
|
|
if (runtimeRegistration.setChannelRuntime) {
|
|
try {
|
|
runtimeRegistration.setChannelRuntime(api.runtime);
|
|
runtimeSetterApplied = true;
|
|
} catch (err) {
|
|
recordPluginError({
|
|
logger,
|
|
registry,
|
|
record,
|
|
seenIds,
|
|
pluginId,
|
|
origin: candidate.origin,
|
|
phase: "load",
|
|
error: err,
|
|
logPrefix: `[plugins] ${record.id} failed to apply setup-runtime channel runtime from ${record.source}: `,
|
|
diagnosticMessagePrefix: "failed to apply setup-runtime channel runtime: ",
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
const runtimePluginRegistration = loadBundledRuntimeChannelPlugin({
|
|
registration: runtimeRegistration,
|
|
});
|
|
if (runtimePluginRegistration.loadError) {
|
|
recordPluginError({
|
|
logger,
|
|
registry,
|
|
record,
|
|
seenIds,
|
|
pluginId,
|
|
origin: candidate.origin,
|
|
phase: "load",
|
|
error: runtimePluginRegistration.loadError,
|
|
logPrefix: `[plugins] ${record.id} failed to load setup-runtime channel entry from ${record.source}: `,
|
|
diagnosticMessagePrefix: "failed to load setup-runtime channel entry: ",
|
|
});
|
|
continue;
|
|
}
|
|
if (runtimePluginRegistration.plugin) {
|
|
if (
|
|
runtimePluginRegistration.plugin.id &&
|
|
runtimePluginRegistration.plugin.id !== record.id
|
|
) {
|
|
pushPluginLoadError(
|
|
`plugin id mismatch (config uses "${record.id}", runtime export uses "${runtimePluginRegistration.plugin.id}")`,
|
|
);
|
|
continue;
|
|
}
|
|
mergedSetupRegistration = {
|
|
...setupRegistration,
|
|
plugin: mergeSetupRuntimeChannelPlugin(
|
|
runtimePluginRegistration.plugin,
|
|
setupRegistration.plugin,
|
|
),
|
|
setChannelRuntime:
|
|
runtimeRegistration.setChannelRuntime ?? setupRegistration.setChannelRuntime,
|
|
};
|
|
}
|
|
}
|
|
const mergedSetupPlugin = mergedSetupRegistration.plugin;
|
|
if (!mergedSetupPlugin) {
|
|
continue;
|
|
}
|
|
if (
|
|
!channelPluginIdBelongsToManifest({
|
|
channelId: mergedSetupPlugin.id,
|
|
pluginId: record.id,
|
|
manifestChannels: manifestRecord.channels,
|
|
})
|
|
) {
|
|
pushPluginLoadError(
|
|
`plugin id mismatch (config uses "${record.id}", setup export uses "${mergedSetupPlugin.id}")`,
|
|
);
|
|
continue;
|
|
}
|
|
if (!runtimeSetterApplied) {
|
|
try {
|
|
mergedSetupRegistration.setChannelRuntime?.(api.runtime);
|
|
} catch (err) {
|
|
recordPluginError({
|
|
logger,
|
|
registry,
|
|
record,
|
|
seenIds,
|
|
pluginId,
|
|
origin: candidate.origin,
|
|
phase: "load",
|
|
error: err,
|
|
logPrefix: `[plugins] ${record.id} failed to apply setup channel runtime from ${record.source}: `,
|
|
diagnosticMessagePrefix: "failed to apply setup channel runtime: ",
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
api.registerChannel(mergedSetupPlugin);
|
|
registry.plugins.push(record);
|
|
seenIds.set(pluginId, candidate.origin);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const resolved = resolvePluginModuleExport(mod);
|
|
const definition = resolved.definition;
|
|
const register = resolved.register;
|
|
|
|
if (definition?.id && definition.id !== record.id) {
|
|
pushPluginLoadError(
|
|
`plugin id mismatch (config uses "${record.id}", export uses "${definition.id}")`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
record.name = definition?.name ?? record.name;
|
|
record.description = definition?.description ?? record.description;
|
|
record.version = definition?.version ?? record.version;
|
|
const manifestKind = record.kind;
|
|
const exportKind = definition?.kind;
|
|
if (manifestKind && exportKind && !kindsEqual(manifestKind, exportKind)) {
|
|
registry.diagnostics.push({
|
|
level: "warn",
|
|
pluginId: record.id,
|
|
source: record.source,
|
|
message: `plugin kind mismatch (manifest uses "${String(manifestKind)}", export uses "${String(exportKind)}")`,
|
|
});
|
|
}
|
|
record.kind = definition?.kind ?? record.kind;
|
|
|
|
if (hasKind(record.kind, "memory") && memorySlot === record.id) {
|
|
memorySlotMatched = true;
|
|
}
|
|
|
|
if (registrationPlan.runRuntimeCapabilityPolicy) {
|
|
if (pluginId !== dreamingEngineId) {
|
|
const memoryDecision = resolveMemorySlotDecision({
|
|
id: record.id,
|
|
kind: record.kind,
|
|
slot: memorySlot,
|
|
selectedId: selectedMemoryPluginId,
|
|
});
|
|
|
|
if (!memoryDecision.enabled) {
|
|
record.enabled = false;
|
|
record.status = "disabled";
|
|
record.error = memoryDecision.reason;
|
|
markPluginActivationDisabled(record, memoryDecision.reason);
|
|
registry.plugins.push(record);
|
|
seenIds.set(pluginId, candidate.origin);
|
|
continue;
|
|
}
|
|
|
|
if (memoryDecision.selected && hasKind(record.kind, "memory")) {
|
|
selectedMemoryPluginId = record.id;
|
|
record.memorySlotSelected = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (registrationPlan.runFullActivationOnlyRegistrations) {
|
|
if (definition?.reload) {
|
|
registerReload(record, definition.reload);
|
|
}
|
|
for (const nodeHostCommand of definition?.nodeHostCommands ?? []) {
|
|
registerNodeHostCommand(record, nodeHostCommand);
|
|
}
|
|
for (const collector of definition?.securityAuditCollectors ?? []) {
|
|
registerSecurityAuditCollector(record, collector);
|
|
}
|
|
}
|
|
|
|
if (validateOnly) {
|
|
registry.plugins.push(record);
|
|
seenIds.set(pluginId, candidate.origin);
|
|
continue;
|
|
}
|
|
|
|
if (typeof register !== "function") {
|
|
logger.error(`[plugins] ${record.id} missing register/activate export`);
|
|
pushPluginLoadError(formatMissingPluginRegisterError(mod, env));
|
|
continue;
|
|
}
|
|
|
|
const api = createApi(record, {
|
|
config: cfg,
|
|
pluginConfig: validatedConfig.value,
|
|
hookPolicy: entry?.hooks,
|
|
registrationMode,
|
|
});
|
|
const registrySnapshot = snapshotPluginRegistry(registry);
|
|
const previousAgentHarnesses = listRegisteredAgentHarnesses();
|
|
const previousCompactionProviders = listRegisteredCompactionProviders();
|
|
const previousDetachedTaskRuntimeRegistration = getDetachedTaskLifecycleRuntimeRegistration();
|
|
const previousMemoryCapability = getMemoryCapabilityRegistration();
|
|
const previousMemoryEmbeddingProviders = listRegisteredMemoryEmbeddingProviders();
|
|
const previousMemoryFlushPlanResolver = getMemoryFlushPlanResolver();
|
|
const previousMemoryPromptBuilder = getMemoryPromptSectionBuilder();
|
|
const previousMemoryCorpusSupplements = listMemoryCorpusSupplements();
|
|
const previousMemoryPromptSupplements = listMemoryPromptSupplements();
|
|
const previousMemoryRuntime = getMemoryRuntime();
|
|
|
|
try {
|
|
withProfile(
|
|
{ pluginId: record.id, source: record.source },
|
|
`${registrationMode}:register`,
|
|
() => runPluginRegisterSync(register, api),
|
|
);
|
|
// Snapshot loads should not replace process-global runtime prompt state.
|
|
if (!shouldActivate) {
|
|
restoreRegisteredAgentHarnesses(previousAgentHarnesses);
|
|
restoreRegisteredCompactionProviders(previousCompactionProviders);
|
|
restoreDetachedTaskLifecycleRuntimeRegistration(previousDetachedTaskRuntimeRegistration);
|
|
restoreRegisteredMemoryEmbeddingProviders(previousMemoryEmbeddingProviders);
|
|
restoreMemoryPluginState({
|
|
capability: previousMemoryCapability,
|
|
corpusSupplements: previousMemoryCorpusSupplements,
|
|
promptBuilder: previousMemoryPromptBuilder,
|
|
promptSupplements: previousMemoryPromptSupplements,
|
|
flushPlanResolver: previousMemoryFlushPlanResolver,
|
|
runtime: previousMemoryRuntime,
|
|
});
|
|
}
|
|
registry.plugins.push(record);
|
|
seenIds.set(pluginId, candidate.origin);
|
|
} catch (err) {
|
|
rollbackPluginGlobalSideEffects(record.id);
|
|
restorePluginRegistry(registry, registrySnapshot);
|
|
restoreRegisteredAgentHarnesses(previousAgentHarnesses);
|
|
restoreRegisteredCompactionProviders(previousCompactionProviders);
|
|
restoreDetachedTaskLifecycleRuntimeRegistration(previousDetachedTaskRuntimeRegistration);
|
|
restoreRegisteredMemoryEmbeddingProviders(previousMemoryEmbeddingProviders);
|
|
restoreMemoryPluginState({
|
|
capability: previousMemoryCapability,
|
|
corpusSupplements: previousMemoryCorpusSupplements,
|
|
promptBuilder: previousMemoryPromptBuilder,
|
|
promptSupplements: previousMemoryPromptSupplements,
|
|
flushPlanResolver: previousMemoryFlushPlanResolver,
|
|
runtime: previousMemoryRuntime,
|
|
});
|
|
recordPluginError({
|
|
logger,
|
|
registry,
|
|
record,
|
|
seenIds,
|
|
pluginId,
|
|
origin: candidate.origin,
|
|
phase: "register",
|
|
error: err,
|
|
logPrefix: `[plugins] ${record.id} failed during register from ${record.source}: `,
|
|
diagnosticMessagePrefix: "plugin failed during register: ",
|
|
});
|
|
}
|
|
}
|
|
|
|
// Scoped snapshot loads may intentionally omit the configured memory plugin, so only
|
|
// emit the missing-memory diagnostic for full registry loads.
|
|
if (!onlyPluginIdSet && typeof memorySlot === "string" && !memorySlotMatched) {
|
|
registry.diagnostics.push({
|
|
level: "warn",
|
|
message: `memory slot plugin not found or not marked as memory: ${memorySlot}`,
|
|
});
|
|
}
|
|
|
|
warnAboutUntrackedLoadedPlugins({
|
|
registry,
|
|
provenance,
|
|
allowlist: normalized.allow,
|
|
emitWarning: shouldActivate,
|
|
logger,
|
|
env,
|
|
});
|
|
|
|
maybeThrowOnPluginLoadError(registry, options.throwOnLoadError);
|
|
|
|
if (shouldActivate && options.mode !== "validate") {
|
|
const failedPlugins = registry.plugins.filter((plugin) => plugin.failedAt != null);
|
|
if (failedPlugins.length > 0) {
|
|
logger.warn(
|
|
`[plugins] ${failedPlugins.length} plugin(s) failed to initialize (${formatPluginFailureSummary(
|
|
failedPlugins,
|
|
)}). Run 'openclaw plugins list' for details.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (cacheEnabled) {
|
|
setCachedPluginRegistry(cacheKey, {
|
|
commands: listRegisteredPluginCommands(),
|
|
detachedTaskRuntimeRegistration: getDetachedTaskLifecycleRuntimeRegistration(),
|
|
interactiveHandlers: listPluginInteractiveHandlers(),
|
|
memoryCapability: getMemoryCapabilityRegistration(),
|
|
memoryCorpusSupplements: listMemoryCorpusSupplements(),
|
|
registry,
|
|
agentHarnesses: listRegisteredAgentHarnesses(),
|
|
compactionProviders: listRegisteredCompactionProviders(),
|
|
memoryEmbeddingProviders: listRegisteredMemoryEmbeddingProviders(),
|
|
memoryFlushPlanResolver: getMemoryFlushPlanResolver(),
|
|
memoryPromptBuilder: getMemoryPromptSectionBuilder(),
|
|
memoryPromptSupplements: listMemoryPromptSupplements(),
|
|
memoryRuntime: getMemoryRuntime(),
|
|
});
|
|
}
|
|
if (shouldActivate) {
|
|
activatePluginRegistry(registry, cacheKey, runtimeSubagentMode, options.workspaceDir);
|
|
}
|
|
return registry;
|
|
} finally {
|
|
inFlightPluginRegistryLoads.delete(cacheKey);
|
|
}
|
|
}
|
|
|
|
export async function loadOpenClawPluginCliRegistry(
|
|
options: PluginLoadOptions = {},
|
|
): Promise<PluginRegistry> {
|
|
const {
|
|
env,
|
|
cfg,
|
|
normalized,
|
|
activationSource,
|
|
autoEnabledReasons,
|
|
onlyPluginIds,
|
|
cacheKey,
|
|
installRecords,
|
|
} = resolvePluginLoadCacheContext({
|
|
...options,
|
|
activate: false,
|
|
cache: false,
|
|
});
|
|
const logger = options.logger ?? defaultLogger();
|
|
const onlyPluginIdSet = createPluginIdScopeSet(onlyPluginIds);
|
|
const getJiti = createPluginJitiLoader(options);
|
|
const { registry, registerCli } = createPluginRegistry({
|
|
logger,
|
|
runtime: {} as PluginRuntime,
|
|
coreGatewayHandlers: options.coreGatewayHandlers as Record<string, GatewayRequestHandler>,
|
|
activateGlobalSideEffects: false,
|
|
});
|
|
|
|
const discovery = discoverOpenClawPlugins({
|
|
workspaceDir: options.workspaceDir,
|
|
extraPaths: normalized.loadPaths,
|
|
cache: false,
|
|
env,
|
|
});
|
|
const manifestRegistry = loadPluginManifestRegistry({
|
|
config: cfg,
|
|
workspaceDir: options.workspaceDir,
|
|
cache: false,
|
|
env,
|
|
candidates: discovery.candidates,
|
|
diagnostics: discovery.diagnostics,
|
|
installRecords: Object.keys(installRecords).length > 0 ? installRecords : undefined,
|
|
});
|
|
pushDiagnostics(registry.diagnostics, manifestRegistry.diagnostics);
|
|
warnWhenAllowlistIsOpen({
|
|
emitWarning: false,
|
|
logger,
|
|
pluginsEnabled: normalized.enabled,
|
|
allow: normalized.allow,
|
|
warningCacheKey: `${cacheKey}::cli-metadata`,
|
|
discoverablePlugins: manifestRegistry.plugins
|
|
.filter((plugin) => !onlyPluginIdSet || onlyPluginIdSet.has(plugin.id))
|
|
.map((plugin) => ({
|
|
id: plugin.id,
|
|
source: plugin.source,
|
|
origin: plugin.origin,
|
|
})),
|
|
});
|
|
const provenance = buildProvenanceIndex({
|
|
config: cfg,
|
|
normalizedLoadPaths: normalized.loadPaths,
|
|
env,
|
|
});
|
|
const manifestByRoot = new Map(
|
|
manifestRegistry.plugins.map((record) => [record.rootDir, record]),
|
|
);
|
|
const orderedCandidates = [...discovery.candidates].toSorted((left, right) => {
|
|
return compareDuplicateCandidateOrder({
|
|
left,
|
|
right,
|
|
manifestByRoot,
|
|
provenance,
|
|
env,
|
|
});
|
|
});
|
|
|
|
const seenIds = new Map<string, PluginRecord["origin"]>();
|
|
const memorySlot = normalized.slots.memory;
|
|
let selectedMemoryPluginId: string | null = null;
|
|
const dreamingEngineId = resolveDreamingSidecarEngineId({ cfg, memorySlot });
|
|
|
|
for (const candidate of orderedCandidates) {
|
|
const manifestRecord = manifestByRoot.get(candidate.rootDir);
|
|
if (!manifestRecord) {
|
|
continue;
|
|
}
|
|
const pluginId = manifestRecord.id;
|
|
if (
|
|
!matchesScopedPluginRequest({
|
|
onlyPluginIdSet,
|
|
pluginId,
|
|
})
|
|
) {
|
|
continue;
|
|
}
|
|
const activationState = resolveEffectivePluginActivationState({
|
|
id: pluginId,
|
|
origin: candidate.origin,
|
|
config: normalized,
|
|
rootConfig: cfg,
|
|
enabledByDefault: manifestRecord.enabledByDefault,
|
|
activationSource,
|
|
autoEnabledReason: formatAutoEnabledActivationReason(autoEnabledReasons[pluginId]),
|
|
});
|
|
const existingOrigin = seenIds.get(pluginId);
|
|
if (existingOrigin) {
|
|
const record = createPluginRecord({
|
|
id: pluginId,
|
|
name: manifestRecord.name ?? pluginId,
|
|
description: manifestRecord.description,
|
|
version: manifestRecord.version,
|
|
format: manifestRecord.format,
|
|
bundleFormat: manifestRecord.bundleFormat,
|
|
bundleCapabilities: manifestRecord.bundleCapabilities,
|
|
source: candidate.source,
|
|
rootDir: candidate.rootDir,
|
|
origin: candidate.origin,
|
|
workspaceDir: candidate.workspaceDir,
|
|
enabled: false,
|
|
activationState,
|
|
configSchema: Boolean(manifestRecord.configSchema),
|
|
contracts: manifestRecord.contracts,
|
|
});
|
|
record.status = "disabled";
|
|
record.error = `overridden by ${existingOrigin} plugin`;
|
|
markPluginActivationDisabled(record, record.error);
|
|
registry.plugins.push(record);
|
|
continue;
|
|
}
|
|
|
|
const enableState = resolveEffectiveEnableState({
|
|
id: pluginId,
|
|
origin: candidate.origin,
|
|
config: normalized,
|
|
rootConfig: cfg,
|
|
enabledByDefault: manifestRecord.enabledByDefault,
|
|
activationSource,
|
|
});
|
|
const entry = normalized.entries[pluginId];
|
|
const record = createPluginRecord({
|
|
id: pluginId,
|
|
name: manifestRecord.name ?? pluginId,
|
|
description: manifestRecord.description,
|
|
version: manifestRecord.version,
|
|
format: manifestRecord.format,
|
|
bundleFormat: manifestRecord.bundleFormat,
|
|
bundleCapabilities: manifestRecord.bundleCapabilities,
|
|
source: candidate.source,
|
|
rootDir: candidate.rootDir,
|
|
origin: candidate.origin,
|
|
workspaceDir: candidate.workspaceDir,
|
|
enabled: enableState.enabled,
|
|
activationState,
|
|
configSchema: Boolean(manifestRecord.configSchema),
|
|
contracts: manifestRecord.contracts,
|
|
});
|
|
record.kind = manifestRecord.kind;
|
|
record.configUiHints = manifestRecord.configUiHints;
|
|
record.configJsonSchema = manifestRecord.configSchema;
|
|
const pushPluginLoadError = (message: string) => {
|
|
record.status = "error";
|
|
record.error = message;
|
|
record.failedAt = new Date();
|
|
record.failurePhase = "validation";
|
|
registry.plugins.push(record);
|
|
seenIds.set(pluginId, candidate.origin);
|
|
registry.diagnostics.push({
|
|
level: "error",
|
|
pluginId: record.id,
|
|
source: record.source,
|
|
message: record.error,
|
|
});
|
|
};
|
|
|
|
if (!enableState.enabled) {
|
|
record.status = "disabled";
|
|
record.error = enableState.reason;
|
|
markPluginActivationDisabled(record, enableState.reason);
|
|
registry.plugins.push(record);
|
|
seenIds.set(pluginId, candidate.origin);
|
|
continue;
|
|
}
|
|
|
|
if (record.format === "bundle") {
|
|
registry.plugins.push(record);
|
|
seenIds.set(pluginId, candidate.origin);
|
|
continue;
|
|
}
|
|
|
|
if (!manifestRecord.configSchema) {
|
|
pushPluginLoadError("missing config schema");
|
|
continue;
|
|
}
|
|
|
|
const validatedConfig = validatePluginConfig({
|
|
schema: manifestRecord.configSchema,
|
|
cacheKey: manifestRecord.schemaCacheKey,
|
|
value: entry?.config,
|
|
});
|
|
if (!validatedConfig.ok) {
|
|
logger.error(`[plugins] ${record.id} invalid config: ${validatedConfig.errors?.join(", ")}`);
|
|
pushPluginLoadError(`invalid config: ${validatedConfig.errors?.join(", ")}`);
|
|
continue;
|
|
}
|
|
|
|
const pluginRoot = safeRealpathOrResolve(candidate.rootDir);
|
|
const cliMetadataSource = resolveCliMetadataEntrySource(candidate.rootDir);
|
|
const sourceForCliMetadata =
|
|
candidate.origin === "bundled"
|
|
? cliMetadataSource
|
|
? safeRealpathOrResolve(cliMetadataSource)
|
|
: null
|
|
: (cliMetadataSource ?? candidate.source);
|
|
if (!sourceForCliMetadata) {
|
|
record.status = "loaded";
|
|
registry.plugins.push(record);
|
|
seenIds.set(pluginId, candidate.origin);
|
|
continue;
|
|
}
|
|
const opened = openBoundaryFileSync({
|
|
absolutePath: sourceForCliMetadata,
|
|
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);
|
|
const safeImportSource = toSafeImportPath(safeSource);
|
|
|
|
let mod: OpenClawPluginModule | null = null;
|
|
try {
|
|
mod = withProfile(
|
|
{ pluginId: record.id, source: safeSource },
|
|
"cli-metadata",
|
|
() => getJiti(safeSource)(safeImportSource) as OpenClawPluginModule,
|
|
);
|
|
} catch (err) {
|
|
recordPluginError({
|
|
logger,
|
|
registry,
|
|
record,
|
|
seenIds,
|
|
pluginId,
|
|
origin: candidate.origin,
|
|
phase: "load",
|
|
error: err,
|
|
logPrefix: `[plugins] ${record.id} failed to load from ${record.source}: `,
|
|
diagnosticMessagePrefix: "failed to load plugin: ",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const resolved = resolvePluginModuleExport(mod);
|
|
const definition = resolved.definition;
|
|
const register = resolved.register;
|
|
|
|
if (definition?.id && definition.id !== record.id) {
|
|
pushPluginLoadError(
|
|
`plugin id mismatch (config uses "${record.id}", export uses "${definition.id}")`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
record.name = definition?.name ?? record.name;
|
|
record.description = definition?.description ?? record.description;
|
|
record.version = definition?.version ?? record.version;
|
|
const manifestKind = record.kind;
|
|
const exportKind = definition?.kind;
|
|
if (manifestKind && exportKind && !kindsEqual(manifestKind, exportKind)) {
|
|
registry.diagnostics.push({
|
|
level: "warn",
|
|
pluginId: record.id,
|
|
source: record.source,
|
|
message: `plugin kind mismatch (manifest uses "${String(manifestKind)}", export uses "${String(exportKind)}")`,
|
|
});
|
|
}
|
|
record.kind = definition?.kind ?? record.kind;
|
|
|
|
if (pluginId !== dreamingEngineId) {
|
|
const memoryDecision = resolveMemorySlotDecision({
|
|
id: record.id,
|
|
kind: record.kind,
|
|
slot: memorySlot,
|
|
selectedId: selectedMemoryPluginId,
|
|
});
|
|
if (!memoryDecision.enabled) {
|
|
record.enabled = false;
|
|
record.status = "disabled";
|
|
record.error = memoryDecision.reason;
|
|
markPluginActivationDisabled(record, memoryDecision.reason);
|
|
registry.plugins.push(record);
|
|
seenIds.set(pluginId, candidate.origin);
|
|
continue;
|
|
}
|
|
if (memoryDecision.selected && hasKind(record.kind, "memory")) {
|
|
selectedMemoryPluginId = record.id;
|
|
record.memorySlotSelected = true;
|
|
}
|
|
}
|
|
|
|
if (typeof register !== "function") {
|
|
logger.error(`[plugins] ${record.id} missing register/activate export`);
|
|
pushPluginLoadError(formatMissingPluginRegisterError(mod, env));
|
|
continue;
|
|
}
|
|
|
|
const api = buildPluginApi({
|
|
id: record.id,
|
|
name: record.name,
|
|
version: record.version,
|
|
description: record.description,
|
|
source: record.source,
|
|
rootDir: record.rootDir,
|
|
registrationMode: "cli-metadata",
|
|
config: cfg,
|
|
pluginConfig: validatedConfig.value,
|
|
runtime: {} as PluginRuntime,
|
|
logger,
|
|
resolvePath: (input) => resolveUserPath(input),
|
|
handlers: {
|
|
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
|
|
},
|
|
});
|
|
|
|
const registrySnapshot = snapshotPluginRegistry(registry);
|
|
try {
|
|
withProfile({ pluginId: record.id, source: record.source }, "cli-metadata:register", () =>
|
|
runPluginRegisterSync(register, api),
|
|
);
|
|
registry.plugins.push(record);
|
|
seenIds.set(pluginId, candidate.origin);
|
|
} catch (err) {
|
|
restorePluginRegistry(registry, registrySnapshot);
|
|
recordPluginError({
|
|
logger,
|
|
registry,
|
|
record,
|
|
seenIds,
|
|
pluginId,
|
|
origin: candidate.origin,
|
|
phase: "register",
|
|
error: err,
|
|
logPrefix: `[plugins] ${record.id} failed during register from ${record.source}: `,
|
|
diagnosticMessagePrefix: "plugin failed during register: ",
|
|
});
|
|
}
|
|
}
|
|
|
|
return registry;
|
|
}
|
|
|
|
function safeRealpathOrResolve(value: string): string {
|
|
try {
|
|
return fs.realpathSync(value);
|
|
} catch {
|
|
return path.resolve(value);
|
|
}
|
|
}
|
|
|
|
function resolveCliMetadataEntrySource(rootDir: string): string | null {
|
|
for (const basename of CLI_METADATA_ENTRY_BASENAMES) {
|
|
const candidate = path.join(rootDir, basename);
|
|
if (fs.existsSync(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
return null;
|
|
}
|