2026-01-18 02:51:42 +00:00
import fs from "node:fs" ;
import path from "node:path" ;
import { fileURLToPath } from "node:url" ;
2026-02-18 01:34:35 +00:00
import { createJiti } from "jiti" ;
2026-03-15 18:46:22 -07:00
import type { ChannelPlugin } from "../channels/plugins/types.js" ;
2026-01-30 03:15:10 +01:00
import type { OpenClawConfig } from "../config/config.js" ;
2026-03-15 19:27:45 -07:00
import { isChannelConfigured } from "../config/plugin-auto-enable.js" ;
2026-03-12 15:31:31 +00:00
import type { PluginInstallRecord } from "../config/types.plugins.js" ;
2026-01-11 12:11:12 +00:00
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js" ;
2026-02-26 13:04:33 +01:00
import { openBoundaryFileSync } from "../infra/boundary-file-read.js" ;
2026-03-08 17:08:33 +00:00
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js" ;
2026-01-18 23:24:42 +00:00
import { createSubsystemLogger } from "../logging/subsystem.js" ;
2026-01-11 12:11:12 +00:00
import { resolveUserPath } from "../utils.js" ;
2026-03-16 21:46:05 -07:00
import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js" ;
2026-02-01 10:03:47 +09:00
import { clearPluginCommands } from "./commands.js" ;
2026-01-19 21:13:51 -06:00
import {
2026-02-07 07:57:50 +00:00
applyTestPluginDefaults ,
2026-01-19 21:13:51 -06:00
normalizePluginsConfig ,
2026-02-23 19:40:32 +00:00
resolveEffectiveEnableState ,
2026-01-19 21:13:51 -06:00
resolveMemorySlotDecision ,
type NormalizedPluginsConfig ,
} from "./config-state.js" ;
2026-02-01 10:03:47 +09:00
import { discoverOpenClawPlugins } from "./discovery.js" ;
2026-01-18 05:40:58 +00:00
import { initializeGlobalHookRunner } from "./hook-runner-global.js" ;
2026-03-15 19:06:11 -04:00
import { clearPluginInteractiveHandlers } from "./interactive.js" ;
2026-02-01 10:03:47 +09:00
import { loadPluginManifestRegistry } from "./manifest-registry.js" ;
2026-02-19 15:24:02 +01:00
import { isPathInside , safeStatSync } from "./path-safety.js" ;
2026-01-14 14:31:43 +00:00
import { createPluginRegistry , type PluginRecord , type PluginRegistry } from "./registry.js" ;
2026-03-12 15:31:31 +00:00
import { resolvePluginCacheInputs } from "./roots.js" ;
2026-01-15 02:42:41 +00:00
import { setActivePluginRegistry } from "./runtime.js" ;
2026-03-15 20:02:24 -07:00
import type { CreatePluginRuntimeOptions } from "./runtime/index.js" ;
2026-03-04 02:58:48 +02:00
import type { PluginRuntime } from "./runtime/types.js" ;
2026-01-19 21:13:51 -06:00
import { validateJsonSchemaValue } from "./schema-validator.js" ;
2026-02-18 01:34:35 +00:00
import type {
OpenClawPluginDefinition ,
OpenClawPluginModule ,
PluginDiagnostic ,
2026-03-15 16:08:30 -07:00
PluginBundleFormat ,
PluginFormat ,
2026-02-18 01:34:35 +00:00
PluginLogger ,
} from "./types.js" ;
2026-01-11 12:11:12 +00:00
export type PluginLoadResult = PluginRegistry ;
export type PluginLoadOptions = {
2026-01-30 03:15:10 +01:00
config? : OpenClawConfig ;
2026-01-11 12:11:12 +00:00
workspaceDir? : string ;
2026-03-12 15:31:31 +00:00
// Allows callers to resolve plugin roots and load paths against an explicit env
// instead of the process-global environment.
env? : NodeJS.ProcessEnv ;
2026-01-11 12:11:12 +00:00
logger? : PluginLogger ;
coreGatewayHandlers? : Record < string , GatewayRequestHandler > ;
2026-03-06 05:31:59 -08:00
runtimeOptions? : CreatePluginRuntimeOptions ;
2026-01-11 12:11:12 +00:00
cache? : boolean ;
2026-01-19 03:38:51 +00:00
mode ? : "full" | "validate" ;
2026-03-16 07:52:08 +08:00
onlyPluginIds? : string [ ] ;
2026-03-15 18:46:22 -07:00
includeSetupOnlyChannelPlugins? : boolean ;
2026-03-16 13:55:53 +00:00
/**
* Prefer `setupEntry` for configured channel plugins that explicitly opt in
* via package metadata because their setup entry covers the pre-listen startup surface.
*/
2026-03-16 13:30:24 +00:00
preferSetupRuntimeForChannelPlugins? : boolean ;
2026-03-16 07:52:08 +08:00
activate? : boolean ;
2026-01-11 12:11:12 +00:00
} ;
2026-03-15 17:29:10 -07:00
const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 128 ;
2026-01-11 12:11:12 +00:00
const registryCache = new Map < string , PluginRegistry > ( ) ;
2026-03-13 11:24:40 -07:00
const openAllowlistWarningCache = new Set < string > ( ) ;
2026-03-16 11:39:26 +00:00
const LAZY_RUNTIME_REFLECTION_KEYS = [
"version" ,
"config" ,
2026-03-16 15:51:08 -05:00
"agent" ,
2026-03-16 11:39:26 +00:00
"subagent" ,
"system" ,
"media" ,
"tts" ,
"stt" ,
"tools" ,
"channel" ,
"events" ,
"logging" ,
"state" ,
"modelAuth" ,
] as const satisfies readonly ( keyof PluginRuntime ) [ ] ;
2026-01-11 12:11:12 +00:00
2026-03-12 15:31:31 +00:00
export function clearPluginLoaderCache ( ) : void {
registryCache . clear ( ) ;
2026-03-13 11:24:40 -07:00
openAllowlistWarningCache . clear ( ) ;
2026-03-12 15:31:31 +00:00
}
2026-01-11 12:11:12 +00:00
const defaultLogger = ( ) = > createSubsystemLogger ( "plugins" ) ;
2026-03-08 00:45:38 +00:00
type PluginSdkAliasCandidateKind = "dist" | "src" ;
2026-03-16 05:12:19 +00:00
type LoaderModuleResolveParams = {
modulePath? : string ;
argv1? : string ;
cwd? : string ;
moduleUrl? : string ;
} ;
function resolveLoaderModulePath ( params : LoaderModuleResolveParams = { } ) : string {
return params . modulePath ? ? fileURLToPath ( params . moduleUrl ? ? import . meta . url ) ;
}
function resolveLoaderPackageRoot (
params : LoaderModuleResolveParams & { modulePath : string } ,
) : string | null {
const cwd = params . cwd ? ? path . dirname ( params . modulePath ) ;
const fromModulePath = resolveOpenClawPackageRootSync ( { cwd } ) ;
if ( fromModulePath ) {
return fromModulePath ;
}
const argv1 = params . argv1 ? ? process . argv [ 1 ] ;
const moduleUrl = params . moduleUrl ? ? ( params . modulePath ? undefined : import . meta . url ) ;
return resolveOpenClawPackageRootSync ( {
cwd ,
. . . ( argv1 ? { argv1 } : { } ) ,
. . . ( moduleUrl ? { moduleUrl } : { } ) ,
} ) ;
}
2026-03-08 00:28:36 +00:00
function resolvePluginSdkAliasCandidateOrder ( params : {
modulePath : string ;
isProduction : boolean ;
2026-03-08 00:45:38 +00:00
} ) : PluginSdkAliasCandidateKind [ ] {
2026-03-08 00:28:36 +00:00
const normalizedModulePath = params . modulePath . replace ( /\\/g , "/" ) ;
const isDistRuntime = normalizedModulePath . includes ( "/dist/" ) ;
return isDistRuntime || params . isProduction ? [ "dist" , "src" ] : [ "src" , "dist" ] ;
}
function listPluginSdkAliasCandidates ( params : {
srcFile : string ;
distFile : string ;
modulePath : string ;
2026-03-16 05:12:19 +00:00
argv1? : string ;
cwd? : string ;
moduleUrl? : string ;
2026-03-08 00:28:36 +00:00
} ) {
const orderedKinds = resolvePluginSdkAliasCandidateOrder ( {
modulePath : params.modulePath ,
isProduction : process.env.NODE_ENV === "production" ,
} ) ;
2026-03-16 05:12:19 +00:00
const packageRoot = resolveLoaderPackageRoot ( params ) ;
if ( packageRoot ) {
const candidateMap = {
src : path.join ( packageRoot , "src" , "plugin-sdk" , params . srcFile ) ,
dist : path.join ( packageRoot , "dist" , "plugin-sdk" , params . distFile ) ,
} as const ;
return orderedKinds . map ( ( kind ) = > candidateMap [ kind ] ) ;
}
2026-03-08 00:28:36 +00:00
let cursor = path . dirname ( params . modulePath ) ;
const candidates : string [ ] = [ ] ;
for ( let i = 0 ; i < 6 ; i += 1 ) {
const candidateMap = {
src : path.join ( cursor , "src" , "plugin-sdk" , params . srcFile ) ,
dist : path.join ( cursor , "dist" , "plugin-sdk" , params . distFile ) ,
} as const ;
for ( const kind of orderedKinds ) {
candidates . push ( candidateMap [ kind ] ) ;
}
const parent = path . dirname ( cursor ) ;
if ( parent === cursor ) {
break ;
}
cursor = parent ;
}
return candidates ;
}
2026-02-15 05:29:39 +00:00
const resolvePluginSdkAliasFile = ( params : {
srcFile : string ;
distFile : string ;
2026-02-26 11:00:09 +01:00
modulePath? : string ;
2026-03-16 05:12:19 +00:00
argv1? : string ;
cwd? : string ;
moduleUrl? : string ;
2026-02-15 05:29:39 +00:00
} ) : string | null = > {
2026-01-18 02:51:42 +00:00
try {
2026-03-16 05:12:19 +00:00
const modulePath = resolveLoaderModulePath ( params ) ;
2026-03-08 00:28:36 +00:00
for ( const candidate of listPluginSdkAliasCandidates ( {
srcFile : params.srcFile ,
distFile : params.distFile ,
modulePath ,
2026-03-16 05:12:19 +00:00
argv1 : params.argv1 ,
cwd : params.cwd ,
moduleUrl : params.moduleUrl ,
2026-03-08 00:28:36 +00:00
} ) ) {
if ( fs . existsSync ( candidate ) ) {
return candidate ;
2026-01-31 16:19:20 +09:00
}
2026-01-18 02:51:42 +00:00
}
} catch {
// ignore
}
return null ;
} ;
2026-02-15 05:29:39 +00:00
const resolvePluginSdkAlias = ( ) : string | null = >
2026-03-04 01:19:17 -05:00
resolvePluginSdkAliasFile ( { srcFile : "root-alias.cjs" , distFile : "root-alias.cjs" } ) ;
2026-02-15 05:29:39 +00:00
2026-03-16 20:58:58 -04:00
function buildPluginLoaderJitiOptions ( aliasMap : Record < string , string > ) {
return {
interopDefault : true ,
// Prefer Node's native sync ESM loader for built dist/*.js modules so
// bundled plugins and plugin-sdk subpaths stay on the canonical module graph.
tryNative : true ,
extensions : [ ".ts" , ".tsx" , ".mts" , ".cts" , ".mtsx" , ".ctsx" , ".js" , ".mjs" , ".cjs" , ".json" ] ,
. . . ( Object . keys ( aliasMap ) . length > 0
? {
alias : aliasMap ,
}
: { } ) ,
} ;
}
2026-03-16 05:12:19 +00:00
function resolvePluginRuntimeModulePath ( params : LoaderModuleResolveParams = { } ) : string | null {
2026-03-15 20:02:24 -07:00
try {
2026-03-16 05:12:19 +00:00
const modulePath = resolveLoaderModulePath ( params ) ;
const orderedKinds = resolvePluginSdkAliasCandidateOrder ( {
modulePath ,
isProduction : process.env.NODE_ENV === "production" ,
} ) ;
const packageRoot = resolveLoaderPackageRoot ( { . . . params , modulePath } ) ;
const candidates = packageRoot
? orderedKinds . map ( ( kind ) = >
kind === "src"
? path . join ( packageRoot , "src" , "plugins" , "runtime" , "index.ts" )
: path . join ( packageRoot , "dist" , "plugins" , "runtime" , "index.js" ) ,
)
: [
path . join ( path . dirname ( modulePath ) , "runtime" , "index.ts" ) ,
path . join ( path . dirname ( modulePath ) , "runtime" , "index.js" ) ,
] ;
2026-03-15 20:02:24 -07:00
for ( const candidate of candidates ) {
if ( fs . existsSync ( candidate ) ) {
return candidate ;
}
}
} catch {
// ignore
}
return null ;
}
2026-03-08 17:08:33 +00:00
const cachedPluginSdkExportedSubpaths = new Map < string , string [ ] > ( ) ;
function listPluginSdkExportedSubpaths ( params : { modulePath? : string } = { } ) : string [ ] {
const modulePath = params . modulePath ? ? fileURLToPath ( import . meta . url ) ;
const packageRoot = resolveOpenClawPackageRootSync ( {
cwd : path.dirname ( modulePath ) ,
} ) ;
if ( ! packageRoot ) {
return [ ] ;
}
const cached = cachedPluginSdkExportedSubpaths . get ( packageRoot ) ;
if ( cached ) {
return cached ;
}
try {
const pkgRaw = fs . readFileSync ( path . join ( packageRoot , "package.json" ) , "utf-8" ) ;
const pkg = JSON . parse ( pkgRaw ) as {
exports? : Record < string , unknown > ;
} ;
const subpaths = Object . keys ( pkg . exports ? ? { } )
. filter ( ( key ) = > key . startsWith ( "./plugin-sdk/" ) )
. map ( ( key ) = > key . slice ( "./plugin-sdk/" . length ) )
. filter ( ( subpath ) = > Boolean ( subpath ) && ! subpath . includes ( "/" ) )
. toSorted ( ) ;
cachedPluginSdkExportedSubpaths . set ( packageRoot , subpaths ) ;
return subpaths ;
} catch {
return [ ] ;
}
}
2026-03-04 02:31:44 -05:00
const resolvePluginSdkScopedAliasMap = ( ) : Record < string , string > = > {
const aliasMap : Record < string , string > = { } ;
2026-03-08 17:08:33 +00:00
for ( const subpath of listPluginSdkExportedSubpaths ( ) ) {
2026-03-04 02:31:44 -05:00
const resolved = resolvePluginSdkAliasFile ( {
2026-03-08 17:08:33 +00:00
srcFile : ` ${ subpath } .ts ` ,
distFile : ` ${ subpath } .js ` ,
2026-03-04 02:31:44 -05:00
} ) ;
if ( resolved ) {
2026-03-08 17:08:33 +00:00
aliasMap [ ` openclaw/plugin-sdk/ ${ subpath } ` ] = resolved ;
2026-03-04 02:31:44 -05:00
}
}
return aliasMap ;
2026-03-03 22:07:03 -05:00
} ;
2026-03-18 16:24:37 +00:00
function shouldPreferNativeJiti ( modulePath : string ) : boolean {
switch ( path . extname ( modulePath ) . toLowerCase ( ) ) {
case ".js" :
case ".mjs" :
case ".cjs" :
case ".json" :
return true ;
default :
return false ;
}
}
2026-02-26 11:00:09 +01:00
export const __testing = {
2026-03-16 20:58:58 -04:00
buildPluginLoaderJitiOptions ,
2026-03-08 00:28:36 +00:00
listPluginSdkAliasCandidates ,
2026-03-08 17:08:33 +00:00
listPluginSdkExportedSubpaths ,
2026-03-08 00:28:36 +00:00
resolvePluginSdkAliasCandidateOrder ,
2026-02-26 11:00:09 +01:00
resolvePluginSdkAliasFile ,
2026-03-16 05:12:19 +00:00
resolvePluginRuntimeModulePath ,
2026-03-18 16:24:37 +00:00
shouldPreferNativeJiti ,
2026-03-12 15:31:31 +00:00
maxPluginRegistryCacheEntries : MAX_PLUGIN_REGISTRY_CACHE_ENTRIES ,
2026-02-26 11:00:09 +01:00
} ;
2026-03-12 15:31:31 +00:00
function getCachedPluginRegistry ( cacheKey : string ) : PluginRegistry | undefined {
const cached = registryCache . get ( cacheKey ) ;
if ( ! cached ) {
return undefined ;
}
// Refresh insertion order so frequently reused registries survive eviction.
registryCache . delete ( cacheKey ) ;
registryCache . set ( cacheKey , cached ) ;
return cached ;
}
function setCachedPluginRegistry ( cacheKey : string , registry : PluginRegistry ) : void {
if ( registryCache . has ( cacheKey ) ) {
registryCache . delete ( cacheKey ) ;
}
registryCache . set ( cacheKey , registry ) ;
while ( registryCache . size > MAX_PLUGIN_REGISTRY_CACHE_ENTRIES ) {
const oldestKey = registryCache . keys ( ) . next ( ) . value ;
if ( ! oldestKey ) {
break ;
}
registryCache . delete ( oldestKey ) ;
}
}
2026-01-11 12:11:12 +00:00
function buildCacheKey ( params : {
workspaceDir? : string ;
plugins : NormalizedPluginsConfig ;
2026-03-12 15:31:31 +00:00
installs? : Record < string , PluginInstallRecord > ;
env : NodeJS.ProcessEnv ;
2026-03-16 07:52:08 +08:00
onlyPluginIds? : string [ ] ;
2026-03-15 18:46:22 -07:00
includeSetupOnlyChannelPlugins? : boolean ;
2026-03-16 13:30:24 +00:00
preferSetupRuntimeForChannelPlugins? : boolean ;
2026-03-16 14:27:54 -07:00
runtimeSubagentMode ? : "default" | "explicit" | "gateway-bindable" ;
2026-01-11 12:11:12 +00:00
} ) : string {
2026-03-12 15:31:31 +00:00
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 ,
} ,
] ) ,
) ;
2026-03-16 07:52:08 +08:00
const scopeKey = JSON . stringify ( params . onlyPluginIds ? ? [ ] ) ;
2026-03-15 18:46:22 -07:00
const setupOnlyKey = params . includeSetupOnlyChannelPlugins === true ? "setup-only" : "runtime" ;
2026-03-16 13:30:24 +00:00
const startupChannelMode =
params . preferSetupRuntimeForChannelPlugins === true ? "prefer-setup" : "full" ;
2026-03-12 15:31:31 +00:00
return ` ${ roots . workspace ? ? "" } :: ${ roots . global ? ? "" } :: ${ roots . stock ? ? "" } :: ${ JSON . stringify ( {
. . . params . plugins ,
installs ,
loadPaths ,
2026-03-16 14:27:54 -07:00
} )}:: ${ scopeKey } :: ${ setupOnlyKey } :: ${ startupChannelMode } :: ${ params . runtimeSubagentMode ? ? "default" } ` ;
2026-03-16 07:52:08 +08:00
}
function normalizeScopedPluginIds ( ids? : string [ ] ) : string [ ] | undefined {
if ( ! ids ) {
return undefined ;
}
const normalized = Array . from ( new Set ( ids . map ( ( id ) = > id . trim ( ) ) . filter ( Boolean ) ) ) . toSorted ( ) ;
return normalized . length > 0 ? normalized : undefined ;
2026-01-11 12:11:12 +00:00
}
function validatePluginConfig ( params : {
2026-01-19 21:13:51 -06:00
schema? : Record < string , unknown > ;
cacheKey? : string ;
value? : unknown ;
2026-01-11 12:11:12 +00:00
} ) : { ok : boolean ; value? : Record < string , unknown > ; errors? : string [ ] } {
const schema = params . schema ;
2026-01-19 21:13:51 -06:00
if ( ! schema ) {
return { ok : true , value : params.value as Record < string , unknown > | undefined } ;
2026-01-11 12:11:12 +00:00
}
2026-01-19 21:13:51 -06:00
const cacheKey = params . cacheKey ? ? JSON . stringify ( schema ) ;
const result = validateJsonSchemaValue ( {
schema ,
cacheKey ,
value : params.value ? ? { } ,
} ) ;
if ( result . ok ) {
return { ok : true , value : params.value as Record < string , unknown > | undefined } ;
2026-01-11 12:11:12 +00:00
}
2026-03-02 20:05:12 -05:00
return { ok : false , errors : result.errors.map ( ( error ) = > error . text ) } ;
2026-01-11 12:11:12 +00:00
}
function resolvePluginModuleExport ( moduleExport : unknown ) : {
2026-01-30 03:15:10 +01:00
definition? : OpenClawPluginDefinition ;
register? : OpenClawPluginDefinition [ "register" ] ;
2026-01-11 12:11:12 +00:00
} {
const resolved =
moduleExport &&
typeof moduleExport === "object" &&
"default" in ( moduleExport as Record < string , unknown > )
? ( moduleExport as { default : unknown } ) . default
: moduleExport ;
if ( typeof resolved === "function" ) {
return {
2026-01-30 03:15:10 +01:00
register : resolved as OpenClawPluginDefinition [ "register" ] ,
2026-01-11 12:11:12 +00:00
} ;
}
if ( resolved && typeof resolved === "object" ) {
2026-01-30 03:15:10 +01:00
const def = resolved as OpenClawPluginDefinition ;
2026-01-11 12:11:12 +00:00
const register = def . register ? ? def . activate ;
return { definition : def , register } ;
}
return { } ;
}
2026-03-15 18:46:22 -07:00
function resolveSetupChannelRegistration ( moduleExport : unknown ) : {
plugin? : ChannelPlugin ;
} {
const resolved =
moduleExport &&
typeof moduleExport === "object" &&
"default" in ( moduleExport as Record < string , unknown > )
? ( moduleExport as { default : unknown } ) . default
: moduleExport ;
if ( ! resolved || typeof resolved !== "object" ) {
return { } ;
}
const setup = resolved as {
plugin? : unknown ;
} ;
if ( ! setup . plugin || typeof setup . plugin !== "object" ) {
return { } ;
}
return {
plugin : setup.plugin as ChannelPlugin ,
} ;
}
2026-03-15 19:27:45 -07:00
function shouldLoadChannelPluginInSetupRuntime ( params : {
manifestChannels : string [ ] ;
setupSource? : string ;
2026-03-16 13:55:53 +00:00
startupDeferConfiguredChannelFullLoadUntilAfterListen? : boolean ;
2026-03-15 19:27:45 -07:00
cfg : OpenClawConfig ;
env : NodeJS.ProcessEnv ;
2026-03-16 13:30:24 +00:00
preferSetupRuntimeForChannelPlugins? : boolean ;
2026-03-15 19:27:45 -07:00
} ) : boolean {
if ( ! params . setupSource || params . manifestChannels . length === 0 ) {
return false ;
}
2026-03-16 13:55:53 +00:00
if (
params . preferSetupRuntimeForChannelPlugins &&
params . startupDeferConfiguredChannelFullLoadUntilAfterListen === true
) {
2026-03-16 13:30:24 +00:00
return true ;
}
2026-03-15 19:27:45 -07:00
return ! params . manifestChannels . some ( ( channelId ) = >
isChannelConfigured ( params . cfg , channelId , params . env ) ,
) ;
}
2026-01-11 12:11:12 +00:00
function createPluginRecord ( params : {
id : string ;
name? : string ;
description? : string ;
version? : string ;
2026-03-15 16:08:30 -07:00
format? : PluginFormat ;
bundleFormat? : PluginBundleFormat ;
bundleCapabilities? : string [ ] ;
2026-01-11 12:11:12 +00:00
source : string ;
2026-03-15 19:06:11 -04:00
rootDir? : string ;
2026-01-11 12:11:12 +00:00
origin : PluginRecord [ "origin" ] ;
workspaceDir? : string ;
enabled : boolean ;
configSchema : boolean ;
} ) : PluginRecord {
return {
id : params.id ,
name : params.name ? ? params . id ,
description : params.description ,
version : params.version ,
2026-03-15 16:08:30 -07:00
format : params.format ? ? "openclaw" ,
bundleFormat : params.bundleFormat ,
bundleCapabilities : params.bundleCapabilities ,
2026-01-11 12:11:12 +00:00
source : params.source ,
2026-03-15 19:06:11 -04:00
rootDir : params.rootDir ,
2026-01-11 12:11:12 +00:00
origin : params.origin ,
workspaceDir : params.workspaceDir ,
enabled : params.enabled ,
status : params.enabled ? "loaded" : "disabled" ,
toolNames : [ ] ,
2026-01-18 05:56:59 +00:00
hookNames : [ ] ,
2026-01-15 02:42:41 +00:00
channelIds : [ ] ,
2026-01-16 03:15:07 +00:00
providerIds : [ ] ,
2026-03-16 18:49:55 -07:00
speechProviderIds : [ ] ,
2026-03-16 20:42:00 -07:00
mediaUnderstandingProviderIds : [ ] ,
2026-03-16 22:56:14 -07:00
imageGenerationProviderIds : [ ] ,
2026-03-16 00:39:27 +00:00
webSearchProviderIds : [ ] ,
2026-01-11 12:11:12 +00:00
gatewayMethods : [ ] ,
cliCommands : [ ] ,
services : [ ] ,
2026-01-23 03:17:10 +00:00
commands : [ ] ,
2026-03-02 16:22:31 +00:00
httpRoutes : 0 ,
2026-01-18 05:40:58 +00:00
hookCount : 0 ,
2026-01-11 12:11:12 +00:00
configSchema : params.configSchema ,
2026-01-12 01:16:39 +00:00
configUiHints : undefined ,
2026-01-16 14:13:30 -06:00
configJsonSchema : undefined ,
2026-01-11 12:11:12 +00:00
} ;
}
2026-02-22 21:18:53 +00:00
function recordPluginError ( params : {
logger : PluginLogger ;
registry : PluginRegistry ;
record : PluginRecord ;
seenIds : Map < string , PluginRecord [ "origin" ] > ;
pluginId : string ;
origin : PluginRecord [ "origin" ] ;
error : unknown ;
logPrefix : string ;
diagnosticMessagePrefix : string ;
} ) {
const errorText = String ( params . error ) ;
2026-03-05 23:23:24 -05:00
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 } ` ) ;
2026-02-22 21:18:53 +00:00
params . record . status = "error" ;
2026-03-05 23:23:24 -05:00
params . record . error = displayError ;
2026-02-22 21:18:53 +00:00
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 ,
2026-03-05 23:23:24 -05:00
message : ` ${ params . diagnosticMessagePrefix } ${ displayError } ` ,
2026-02-22 21:18:53 +00:00
} ) ;
}
2026-01-14 14:31:43 +00:00
function pushDiagnostics ( diagnostics : PluginDiagnostic [ ] , append : PluginDiagnostic [ ] ) {
2026-01-11 12:11:12 +00:00
diagnostics . push ( . . . append ) ;
}
2026-02-19 15:24:02 +01:00
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 : [ ] } ;
}
2026-03-12 15:31:31 +00:00
function addPathToMatcher (
matcher : PathMatcher ,
rawPath : string ,
env : NodeJS.ProcessEnv = process . env ,
) : void {
2026-02-19 15:24:02 +01:00
const trimmed = rawPath . trim ( ) ;
if ( ! trimmed ) {
return ;
2026-02-19 15:13:34 +01:00
}
2026-03-12 15:31:31 +00:00
const resolved = resolveUserPath ( trimmed , env ) ;
2026-02-19 15:24:02 +01:00
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 ) ;
2026-02-19 15:13:34 +01:00
}
2026-02-19 15:24:02 +01:00
function matchesPathMatcher ( matcher : PathMatcher , sourcePath : string ) : boolean {
if ( matcher . exact . has ( sourcePath ) ) {
2026-02-19 15:13:34 +01:00
return true ;
}
2026-02-19 15:24:02 +01:00
return matcher . dirs . some ( ( dirPath ) = > isPathInside ( dirPath , sourcePath ) ) ;
}
function buildProvenanceIndex ( params : {
config : OpenClawConfig ;
normalizedLoadPaths : string [ ] ;
2026-03-12 15:31:31 +00:00
env : NodeJS.ProcessEnv ;
2026-02-19 15:24:02 +01:00
} ) : PluginProvenanceIndex {
const loadPathMatcher = createPathMatcher ( ) ;
for ( const loadPath of params . normalizedLoadPaths ) {
2026-03-12 15:31:31 +00:00
addPathToMatcher ( loadPathMatcher , loadPath , params . env ) ;
2026-02-19 15:24:02 +01:00
}
const installRules = new Map < string , InstallTrackingRule > ( ) ;
const installs = params . config . plugins ? . installs ? ? { } ;
for ( const [ pluginId , install ] of Object . entries ( installs ) ) {
const rule : InstallTrackingRule = {
trackedWithoutPaths : false ,
matcher : createPathMatcher ( ) ,
} ;
const trackedPaths = [ install . installPath , install . sourcePath ]
. map ( ( entry ) = > ( typeof entry === "string" ? entry . trim ( ) : "" ) )
. filter ( Boolean ) ;
if ( trackedPaths . length === 0 ) {
rule . trackedWithoutPaths = true ;
} else {
for ( const trackedPath of trackedPaths ) {
2026-03-12 15:31:31 +00:00
addPathToMatcher ( rule . matcher , trackedPath , params . env ) ;
2026-02-19 15:24:02 +01:00
}
2026-02-19 15:13:34 +01:00
}
2026-02-19 15:24:02 +01:00
installRules . set ( pluginId , rule ) ;
2026-02-19 15:13:34 +01:00
}
2026-02-19 15:24:02 +01:00
return { loadPathMatcher , installRules } ;
2026-02-19 15:13:34 +01:00
}
2026-02-19 15:24:02 +01:00
function isTrackedByProvenance ( params : {
2026-02-19 15:13:34 +01:00
pluginId : string ;
source : string ;
2026-02-19 15:24:02 +01:00
index : PluginProvenanceIndex ;
2026-03-12 15:31:31 +00:00
env : NodeJS.ProcessEnv ;
2026-02-19 15:13:34 +01:00
} ) : boolean {
2026-03-12 15:31:31 +00:00
const sourcePath = resolveUserPath ( params . source , params . env ) ;
2026-02-19 15:24:02 +01:00
const installRule = params . index . installRules . get ( params . pluginId ) ;
if ( installRule ) {
if ( installRule . trackedWithoutPaths ) {
return true ;
}
if ( matchesPathMatcher ( installRule . matcher , sourcePath ) ) {
return true ;
}
2026-02-19 15:13:34 +01:00
}
2026-02-19 15:24:02 +01:00
return matchesPathMatcher ( params . index . loadPathMatcher , sourcePath ) ;
2026-02-19 15:13:34 +01:00
}
2026-03-14 21:08:32 -05:00
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 ,
} ) ;
2026-03-15 09:07:10 -07:00
if ( params . candidate . origin === "config" ) {
return 0 ;
2026-03-14 21:08:32 -05:00
}
2026-03-15 09:07:10 -07:00
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 ;
2026-03-14 21:08:32 -05:00
}
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 ,
} )
) ;
}
2026-02-19 15:13:34 +01:00
function warnWhenAllowlistIsOpen ( params : {
logger : PluginLogger ;
pluginsEnabled : boolean ;
allow : string [ ] ;
2026-03-13 11:24:40 -07:00
warningCacheKey : string ;
2026-02-19 15:13:34 +01:00
discoverablePlugins : Array < { id : string ; source : string ; origin : PluginRecord [ "origin" ] } > ;
} ) {
if ( ! params . pluginsEnabled ) {
return ;
}
if ( params . allow . length > 0 ) {
return ;
}
const nonBundled = params . discoverablePlugins . filter ( ( entry ) = > entry . origin !== "bundled" ) ;
if ( nonBundled . length === 0 ) {
return ;
}
2026-03-13 11:24:40 -07:00
if ( openAllowlistWarningCache . has ( params . warningCacheKey ) ) {
return ;
}
2026-02-19 15:13:34 +01:00
const preview = nonBundled
. slice ( 0 , 6 )
. map ( ( entry ) = > ` ${ entry . id } ( ${ entry . source } ) ` )
. join ( ", " ) ;
const extra = nonBundled . length > 6 ? ` (+ ${ nonBundled . length - 6 } more) ` : "" ;
2026-03-13 11:24:40 -07:00
openAllowlistWarningCache . add ( params . warningCacheKey ) ;
2026-02-19 15:13:34 +01:00
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 ;
2026-02-19 15:24:02 +01:00
provenance : PluginProvenanceIndex ;
2026-02-19 15:13:34 +01:00
logger : PluginLogger ;
2026-03-12 15:31:31 +00:00
env : NodeJS.ProcessEnv ;
2026-02-19 15:13:34 +01:00
} ) {
for ( const plugin of params . registry . plugins ) {
if ( plugin . status !== "loaded" || plugin . origin === "bundled" ) {
continue ;
}
if (
2026-02-19 15:24:02 +01:00
isTrackedByProvenance ( {
2026-02-19 15:13:34 +01:00
pluginId : plugin.id ,
source : plugin.source ,
2026-02-19 15:24:02 +01:00
index : params.provenance ,
2026-03-12 15:31:31 +00:00
env : params.env ,
2026-02-19 15:13:34 +01:00
} )
) {
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 ,
} ) ;
params . logger . warn ( ` [plugins] ${ plugin . id } : ${ message } ( ${ plugin . source } ) ` ) ;
}
}
2026-03-02 04:04:02 +00:00
function activatePluginRegistry ( registry : PluginRegistry , cacheKey : string ) : void {
setActivePluginRegistry ( registry , cacheKey ) ;
initializeGlobalHookRunner ( registry ) ;
}
2026-01-30 03:15:10 +01:00
export function loadOpenClawPlugins ( options : PluginLoadOptions = { } ) : PluginRegistry {
2026-03-16 07:52:08 +08:00
// Snapshot (non-activating) loads must disable the cache to avoid storing a registry
// whose commands were never globally registered.
if ( options . activate === false && options . cache !== false ) {
throw new Error (
"loadOpenClawPlugins: activate:false requires cache:false to prevent command registry divergence" ,
) ;
}
2026-03-12 15:31:31 +00:00
const env = options . env ? ? process . env ;
2026-02-07 07:57:50 +00:00
// Test env: default-disable plugins unless explicitly configured.
// This keeps unit/gateway suites fast and avoids loading heavyweight plugin deps by accident.
2026-03-12 15:31:31 +00:00
const cfg = applyTestPluginDefaults ( options . config ? ? { } , env ) ;
2026-01-11 12:11:12 +00:00
const logger = options . logger ? ? defaultLogger ( ) ;
2026-01-19 03:38:51 +00:00
const validateOnly = options . mode === "validate" ;
2026-01-11 12:11:12 +00:00
const normalized = normalizePluginsConfig ( cfg . plugins ) ;
2026-03-16 07:52:08 +08:00
const onlyPluginIds = normalizeScopedPluginIds ( options . onlyPluginIds ) ;
const onlyPluginIdSet = onlyPluginIds ? new Set ( onlyPluginIds ) : null ;
2026-03-15 18:46:22 -07:00
const includeSetupOnlyChannelPlugins = options . includeSetupOnlyChannelPlugins === true ;
2026-03-16 13:30:24 +00:00
const preferSetupRuntimeForChannelPlugins = options . preferSetupRuntimeForChannelPlugins === true ;
2026-03-16 07:52:08 +08:00
const shouldActivate = options . activate !== false ;
// NOTE: `activate` is intentionally excluded from the cache key. All non-activating
// (snapshot) callers pass `cache: false` via loadOnboardingPluginRegistry(), so they
// never read from or write to the cache. Including `activate` here would be misleading
// — it would imply mixed-activate caching is supported, when in practice it is not.
2026-01-11 12:11:12 +00:00
const cacheKey = buildCacheKey ( {
workspaceDir : options.workspaceDir ,
plugins : normalized ,
2026-03-12 15:31:31 +00:00
installs : cfg.plugins?.installs ,
env ,
2026-03-16 07:52:08 +08:00
onlyPluginIds ,
2026-03-15 18:46:22 -07:00
includeSetupOnlyChannelPlugins ,
2026-03-16 13:30:24 +00:00
preferSetupRuntimeForChannelPlugins ,
2026-03-16 14:27:54 -07:00
runtimeSubagentMode :
options.runtimeOptions?.allowGatewaySubagentBinding === true
? "gateway-bindable"
: options . runtimeOptions ? . subagent
? "explicit"
: "default" ,
2026-01-11 12:11:12 +00:00
} ) ;
const cacheEnabled = options . cache !== false ;
if ( cacheEnabled ) {
2026-03-12 15:31:31 +00:00
const cached = getCachedPluginRegistry ( cacheKey ) ;
2026-01-15 02:42:41 +00:00
if ( cached ) {
2026-03-16 07:52:08 +08:00
if ( shouldActivate ) {
activatePluginRegistry ( cached , cacheKey ) ;
}
2026-01-15 02:42:41 +00:00
return cached ;
}
2026-01-11 12:11:12 +00:00
}
2026-03-16 07:52:08 +08:00
// Clear previously registered plugin commands before reloading.
// Skip for non-activating (snapshot) loads to avoid wiping commands from other plugins.
if ( shouldActivate ) {
clearPluginCommands ( ) ;
clearPluginInteractiveHandlers ( ) ;
}
2026-01-23 12:43:39 +00:00
2026-03-15 20:02:24 -07:00
// Lazy: avoid creating the Jiti loader when all plugins are disabled (common in unit tests).
2026-03-18 16:24:37 +00:00
const jitiLoaders = new Map < boolean , ReturnType < typeof createJiti > > ( ) ;
const getJiti = ( modulePath : string ) = > {
const tryNative = shouldPreferNativeJiti ( modulePath ) ;
const cached = jitiLoaders . get ( tryNative ) ;
if ( cached ) {
return cached ;
2026-03-15 20:02:24 -07:00
}
const pluginSdkAlias = resolvePluginSdkAlias ( ) ;
const aliasMap = {
. . . ( pluginSdkAlias ? { "openclaw/plugin-sdk" : pluginSdkAlias } : { } ) ,
. . . resolvePluginSdkScopedAliasMap ( ) ,
} ;
2026-03-18 16:24:37 +00:00
const loader = createJiti ( import . meta . url , {
. . . buildPluginLoaderJitiOptions ( aliasMap ) ,
// 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 ,
} ) ;
jitiLoaders . set ( tryNative , loader ) ;
return loader ;
2026-03-15 20:02:24 -07:00
} ;
let createPluginRuntimeFactory : ( ( options? : CreatePluginRuntimeOptions ) = > PluginRuntime ) | null =
null ;
const resolveCreatePluginRuntime = ( ) : ( (
options? : CreatePluginRuntimeOptions ,
) = > PluginRuntime ) = > {
if ( createPluginRuntimeFactory ) {
return createPluginRuntimeFactory ;
}
const runtimeModulePath = resolvePluginRuntimeModulePath ( ) ;
if ( ! runtimeModulePath ) {
throw new Error ( "Unable to resolve plugin runtime module" ) ;
}
2026-03-18 16:24:37 +00:00
const runtimeModule = getJiti ( runtimeModulePath ) ( runtimeModulePath ) as {
2026-03-15 20:02:24 -07:00
createPluginRuntime ? : ( options? : CreatePluginRuntimeOptions ) = > PluginRuntime ;
} ;
if ( typeof runtimeModule . createPluginRuntime !== "function" ) {
throw new Error ( "Plugin runtime module missing createPluginRuntime export" ) ;
}
createPluginRuntimeFactory = runtimeModule . createPluginRuntime ;
return createPluginRuntimeFactory ;
} ;
2026-03-04 02:58:48 +02:00
// Lazily initialize the runtime so startup paths that discover/skip plugins do
2026-03-15 20:02:24 -07:00
// not eagerly load every channel/runtime dependency tree.
2026-03-04 02:58:48 +02:00
let resolvedRuntime : PluginRuntime | null = null ;
const resolveRuntime = ( ) : PluginRuntime = > {
2026-03-15 20:02:24 -07:00
resolvedRuntime ? ? = resolveCreatePluginRuntime ( ) ( options . runtimeOptions ) ;
2026-03-04 02:58:48 +02:00
return resolvedRuntime ;
} ;
2026-03-16 11:39:26 +00:00
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 ) ;
} ,
} ;
} ;
2026-03-04 02:58:48 +02:00
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 ) {
2026-03-16 11:39:26 +00:00
return lazyRuntimeReflectionKeySet . has ( prop ) || Reflect . has ( resolveRuntime ( ) , prop ) ;
2026-03-04 02:58:48 +02:00
} ,
ownKeys() {
2026-03-16 11:39:26 +00:00
return [ . . . LAZY_RUNTIME_REFLECTION_KEYS ] ;
2026-03-04 02:58:48 +02:00
} ,
getOwnPropertyDescriptor ( _target , prop ) {
2026-03-16 11:39:26 +00:00
return resolveLazyRuntimeDescriptor ( prop ) ;
2026-03-04 02:58:48 +02:00
} ,
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 ) ;
} ,
} ) ;
2026-03-15 20:02:24 -07:00
2026-01-11 12:11:12 +00:00
const { registry , createApi } = createPluginRegistry ( {
logger ,
2026-01-18 02:14:07 +00:00
runtime ,
2026-01-14 14:31:43 +00:00
coreGatewayHandlers : options.coreGatewayHandlers as Record < string , GatewayRequestHandler > ,
2026-03-16 07:52:08 +08:00
suppressGlobalCommands : ! shouldActivate ,
2026-01-11 12:11:12 +00:00
} ) ;
2026-01-30 03:15:10 +01:00
const discovery = discoverOpenClawPlugins ( {
2026-01-11 12:11:12 +00:00
workspaceDir : options.workspaceDir ,
extraPaths : normalized.loadPaths ,
2026-03-04 01:19:17 -05:00
cache : options.cache ,
2026-03-12 15:31:31 +00:00
env ,
2026-01-11 12:11:12 +00:00
} ) ;
2026-01-19 21:13:51 -06:00
const manifestRegistry = loadPluginManifestRegistry ( {
config : cfg ,
workspaceDir : options.workspaceDir ,
cache : options.cache ,
2026-03-12 15:31:31 +00:00
env ,
2026-01-19 21:13:51 -06:00
candidates : discovery.candidates ,
diagnostics : discovery.diagnostics ,
} ) ;
pushDiagnostics ( registry . diagnostics , manifestRegistry . diagnostics ) ;
2026-02-19 15:13:34 +01:00
warnWhenAllowlistIsOpen ( {
logger ,
pluginsEnabled : normalized.enabled ,
allow : normalized.allow ,
2026-03-13 11:24:40 -07:00
warningCacheKey : cacheKey ,
2026-03-16 07:52:08 +08:00
// 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 ,
} ) ) ,
2026-02-19 15:13:34 +01:00
} ) ;
2026-02-19 15:24:02 +01:00
const provenance = buildProvenanceIndex ( {
config : cfg ,
normalizedLoadPaths : normalized.loadPaths ,
2026-03-12 15:31:31 +00:00
env ,
2026-02-19 15:24:02 +01:00
} ) ;
2026-01-11 12:11:12 +00:00
2026-01-19 21:13:51 -06:00
const manifestByRoot = new Map (
manifestRegistry . plugins . map ( ( record ) = > [ record . rootDir , record ] ) ,
2026-01-20 08:47:44 +00:00
) ;
2026-03-14 21:08:32 -05:00
const orderedCandidates = [ . . . discovery . candidates ] . toSorted ( ( left , right ) = > {
return compareDuplicateCandidateOrder ( {
left ,
right ,
manifestByRoot ,
provenance ,
env ,
} ) ;
} ) ;
2026-01-20 08:47:44 +00:00
2026-01-17 09:33:56 +00:00
const seenIds = new Map < string , PluginRecord [ "origin" ] > ( ) ;
2026-01-18 02:12:01 +00:00
const memorySlot = normalized . slots . memory ;
let selectedMemoryPluginId : string | null = null ;
let memorySlotMatched = false ;
2026-01-17 09:33:56 +00:00
2026-03-14 21:08:32 -05:00
for ( const candidate of orderedCandidates ) {
2026-01-19 21:13:51 -06:00
const manifestRecord = manifestByRoot . get ( candidate . rootDir ) ;
if ( ! manifestRecord ) {
continue ;
}
const pluginId = manifestRecord . id ;
2026-03-16 07:52:08 +08:00
// 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 ( onlyPluginIdSet && ! onlyPluginIdSet . has ( pluginId ) ) {
continue ;
}
2026-01-19 21:13:51 -06:00
const existingOrigin = seenIds . get ( pluginId ) ;
2026-01-17 09:33:56 +00:00
if ( existingOrigin ) {
const record = createPluginRecord ( {
2026-01-19 21:13:51 -06:00
id : pluginId ,
name : manifestRecord.name ? ? pluginId ,
description : manifestRecord.description ,
version : manifestRecord.version ,
2026-03-15 16:08:30 -07:00
format : manifestRecord.format ,
bundleFormat : manifestRecord.bundleFormat ,
bundleCapabilities : manifestRecord.bundleCapabilities ,
2026-01-17 09:33:56 +00:00
source : candidate.source ,
2026-03-15 19:06:11 -04:00
rootDir : candidate.rootDir ,
2026-01-17 09:33:56 +00:00
origin : candidate.origin ,
workspaceDir : candidate.workspaceDir ,
enabled : false ,
2026-01-19 21:13:51 -06:00
configSchema : Boolean ( manifestRecord . configSchema ) ,
2026-01-17 09:33:56 +00:00
} ) ;
record . status = "disabled" ;
record . error = ` overridden by ${ existingOrigin } plugin ` ;
registry . plugins . push ( record ) ;
continue ;
}
2026-02-23 19:40:32 +00:00
const enableState = resolveEffectiveEnableState ( {
id : pluginId ,
origin : candidate.origin ,
config : normalized ,
rootConfig : cfg ,
2026-03-17 09:35:21 -07:00
enabledByDefault : manifestRecord.enabledByDefault ,
2026-02-23 19:40:32 +00:00
} ) ;
2026-01-19 21:13:51 -06:00
const entry = normalized . entries [ pluginId ] ;
2026-01-11 12:11:12 +00:00
const record = createPluginRecord ( {
2026-01-19 21:13:51 -06:00
id : pluginId ,
name : manifestRecord.name ? ? pluginId ,
description : manifestRecord.description ,
version : manifestRecord.version ,
2026-03-15 16:08:30 -07:00
format : manifestRecord.format ,
bundleFormat : manifestRecord.bundleFormat ,
bundleCapabilities : manifestRecord.bundleCapabilities ,
2026-01-11 12:11:12 +00:00
source : candidate.source ,
2026-03-15 19:06:11 -04:00
rootDir : candidate.rootDir ,
2026-01-11 12:11:12 +00:00
origin : candidate.origin ,
workspaceDir : candidate.workspaceDir ,
enabled : enableState.enabled ,
2026-01-19 21:13:51 -06:00
configSchema : Boolean ( manifestRecord . configSchema ) ,
2026-01-11 12:11:12 +00:00
} ) ;
2026-01-19 21:13:51 -06:00
record . kind = manifestRecord . kind ;
record . configUiHints = manifestRecord . configUiHints ;
record . configJsonSchema = manifestRecord . configSchema ;
2026-03-02 21:31:18 +00:00
const pushPluginLoadError = ( message : string ) = > {
record . status = "error" ;
record . error = message ;
registry . plugins . push ( record ) ;
seenIds . set ( pluginId , candidate . origin ) ;
registry . diagnostics . push ( {
level : "error" ,
pluginId : record.id ,
source : record.source ,
message : record.error ,
} ) ;
} ;
2026-01-11 12:11:12 +00:00
2026-03-15 16:17:24 -07:00
const registrationMode = enableState . enabled
2026-03-15 19:27:45 -07:00
? ! validateOnly &&
shouldLoadChannelPluginInSetupRuntime ( {
manifestChannels : manifestRecord.channels ,
setupSource : manifestRecord.setupSource ,
2026-03-16 13:55:53 +00:00
startupDeferConfiguredChannelFullLoadUntilAfterListen :
manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen ,
2026-03-15 19:27:45 -07:00
cfg ,
env ,
2026-03-16 13:30:24 +00:00
preferSetupRuntimeForChannelPlugins ,
2026-03-15 19:27:45 -07:00
} )
? "setup-runtime"
: "full"
2026-03-15 18:46:22 -07:00
: includeSetupOnlyChannelPlugins && ! validateOnly && manifestRecord . channels . length > 0
2026-03-15 16:17:24 -07:00
? "setup-only"
: null ;
if ( ! registrationMode ) {
2026-01-11 12:11:12 +00:00
record . status = "disabled" ;
record . error = enableState . reason ;
registry . plugins . push ( record ) ;
2026-01-19 21:13:51 -06:00
seenIds . set ( pluginId , candidate . origin ) ;
continue ;
}
2026-03-15 16:17:24 -07:00
if ( ! enableState . enabled ) {
record . status = "disabled" ;
record . error = enableState . reason ;
}
2026-01-19 21:13:51 -06:00
2026-03-15 16:08:30 -07:00
if ( record . format === "bundle" ) {
const unsupportedCapabilities = ( record . bundleCapabilities ? ? [ ] ) . filter (
( capability ) = >
capability !== "skills" &&
2026-03-16 21:46:05 -07:00
capability !== "mcpServers" &&
2026-03-15 16:08:30 -07:00
capability !== "settings" &&
! (
2026-03-18 00:11:37 -07:00
( capability === "commands" ||
capability === "agents" ||
capability === "outputStyles" ||
capability === "lspServers" ) &&
2026-03-15 16:08:30 -07:00
( record . bundleFormat === "claude" || record . bundleFormat === "cursor" )
) &&
2026-03-17 23:31:38 -07:00
! (
capability === "hooks" &&
( record . bundleFormat === "codex" || record . bundleFormat === "claude" )
) ,
2026-03-15 16:08:30 -07:00
) ;
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 } ` ,
} ) ;
}
2026-03-16 21:46:05 -07:00
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 ( ", " ) } ` ,
} ) ;
}
}
2026-03-15 16:08:30 -07:00
registry . plugins . push ( record ) ;
seenIds . set ( pluginId , candidate . origin ) ;
continue ;
}
2026-03-04 01:19:17 -05:00
// Fast-path bundled memory plugins that are guaranteed disabled by slot policy.
// This avoids opening/importing heavy memory plugin modules that will never register.
2026-03-15 16:17:24 -07:00
if (
registrationMode === "full" &&
candidate . origin === "bundled" &&
manifestRecord . kind === "memory"
) {
2026-03-04 01:19:17 -05:00
const earlyMemoryDecision = resolveMemorySlotDecision ( {
id : record.id ,
kind : "memory" ,
slot : memorySlot ,
selectedId : selectedMemoryPluginId ,
} ) ;
if ( ! earlyMemoryDecision . enabled ) {
record . enabled = false ;
record . status = "disabled" ;
record . error = earlyMemoryDecision . reason ;
registry . plugins . push ( record ) ;
seenIds . set ( pluginId , candidate . origin ) ;
continue ;
}
}
2026-01-19 21:13:51 -06:00
if ( ! manifestRecord . configSchema ) {
2026-03-02 21:31:18 +00:00
pushPluginLoadError ( "missing config schema" ) ;
2026-01-11 12:11:12 +00:00
continue ;
}
2026-02-26 13:04:33 +01:00
const pluginRoot = safeRealpathOrResolve ( candidate . rootDir ) ;
2026-03-15 18:46:22 -07:00
const loadSource =
2026-03-15 19:27:45 -07:00
( registrationMode === "setup-only" || registrationMode === "setup-runtime" ) &&
manifestRecord . setupSource
2026-03-15 18:46:22 -07:00
? manifestRecord.setupSource
: candidate.source ;
2026-02-26 13:04:33 +01:00
const opened = openBoundaryFileSync ( {
2026-03-15 18:46:22 -07:00
absolutePath : loadSource ,
2026-02-26 13:04:33 +01:00
rootPath : pluginRoot ,
boundaryLabel : "plugin root" ,
2026-03-02 22:10:31 +00:00
rejectHardlinks : candidate.origin !== "bundled" ,
2026-02-26 12:40:57 +00:00
skipLexicalRootCheck : true ,
2026-02-26 13:04:33 +01:00
} ) ;
if ( ! opened . ok ) {
2026-03-02 21:31:18 +00:00
pushPluginLoadError ( "plugin entry path escapes plugin root or fails alias checks" ) ;
2026-02-19 15:34:58 +01:00
continue ;
}
2026-02-26 13:04:33 +01:00
const safeSource = opened . path ;
fs . closeSync ( opened . fd ) ;
2026-02-19 15:34:58 +01:00
2026-01-30 03:15:10 +01:00
let mod : OpenClawPluginModule | null = null ;
2026-01-11 12:11:12 +00:00
try {
2026-03-18 16:24:37 +00:00
mod = getJiti ( safeSource ) ( safeSource ) as OpenClawPluginModule ;
2026-01-11 12:11:12 +00:00
} catch ( err ) {
2026-02-22 21:18:53 +00:00
recordPluginError ( {
logger ,
registry ,
record ,
seenIds ,
pluginId ,
origin : candidate.origin ,
error : err ,
logPrefix : ` [plugins] ${ record . id } failed to load from ${ record . source } : ` ,
diagnosticMessagePrefix : "failed to load plugin: " ,
2026-01-11 12:11:12 +00:00
} ) ;
continue ;
}
2026-03-15 19:27:45 -07:00
if (
( registrationMode === "setup-only" || registrationMode === "setup-runtime" ) &&
manifestRecord . setupSource
) {
2026-03-15 18:46:22 -07:00
const setupRegistration = resolveSetupChannelRegistration ( mod ) ;
if ( setupRegistration . plugin ) {
if ( setupRegistration . plugin . id && setupRegistration . plugin . id !== record . id ) {
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 ,
} ) ;
2026-03-16 00:09:28 -07:00
api . registerChannel ( setupRegistration . plugin ) ;
2026-03-15 18:46:22 -07:00
registry . plugins . push ( record ) ;
seenIds . set ( pluginId , candidate . origin ) ;
continue ;
}
}
2026-01-11 12:11:12 +00:00
const resolved = resolvePluginModuleExport ( mod ) ;
const definition = resolved . definition ;
const register = resolved . register ;
if ( definition ? . id && definition . id !== record . id ) {
2026-03-13 19:13:35 -07:00
pushPluginLoadError (
` plugin id mismatch (config uses " ${ record . id } ", export uses " ${ definition . id } ") ` ,
) ;
continue ;
2026-01-11 12:11:12 +00:00
}
record . name = definition ? . name ? ? record . name ;
record . description = definition ? . description ? ? record . description ;
record . version = definition ? . version ? ? record . version ;
2026-01-19 21:13:51 -06:00
const manifestKind = record . kind as string | undefined ;
const exportKind = definition ? . kind as string | undefined ;
if ( manifestKind && exportKind && exportKind !== manifestKind ) {
2026-01-19 03:38:51 +00:00
registry . diagnostics . push ( {
2026-01-19 21:13:51 -06:00
level : "warn" ,
2026-01-19 03:38:51 +00:00
pluginId : record.id ,
source : record.source ,
2026-01-19 21:13:51 -06:00
message : ` plugin kind mismatch (manifest uses " ${ manifestKind } ", export uses " ${ exportKind } ") ` ,
2026-01-19 03:38:51 +00:00
} ) ;
}
2026-01-19 21:13:51 -06:00
record . kind = definition ? . kind ? ? record . kind ;
2026-01-19 03:38:51 +00:00
2026-01-18 02:12:01 +00:00
if ( record . kind === "memory" && memorySlot === record . id ) {
memorySlotMatched = true ;
}
2026-03-15 16:17:24 -07:00
if ( registrationMode === "full" ) {
const memoryDecision = resolveMemorySlotDecision ( {
id : record.id ,
kind : record.kind ,
slot : memorySlot ,
selectedId : selectedMemoryPluginId ,
} ) ;
2026-01-18 02:12:01 +00:00
2026-03-15 16:17:24 -07:00
if ( ! memoryDecision . enabled ) {
record . enabled = false ;
record . status = "disabled" ;
record . error = memoryDecision . reason ;
registry . plugins . push ( record ) ;
seenIds . set ( pluginId , candidate . origin ) ;
continue ;
}
2026-01-18 02:12:01 +00:00
2026-03-15 16:17:24 -07:00
if ( memoryDecision . selected && record . kind === "memory" ) {
selectedMemoryPluginId = record . id ;
}
2026-01-18 02:12:01 +00:00
}
2026-01-11 12:11:12 +00:00
const validatedConfig = validatePluginConfig ( {
2026-01-19 21:13:51 -06:00
schema : manifestRecord.configSchema ,
cacheKey : manifestRecord.schemaCacheKey ,
2026-01-11 12:11:12 +00:00
value : entry?.config ,
} ) ;
if ( ! validatedConfig . ok ) {
2026-01-19 00:34:16 +00:00
logger . error ( ` [plugins] ${ record . id } invalid config: ${ validatedConfig . errors ? . join ( ", " ) } ` ) ;
2026-03-02 21:31:18 +00:00
pushPluginLoadError ( ` invalid config: ${ validatedConfig . errors ? . join ( ", " ) } ` ) ;
2026-01-11 12:11:12 +00:00
continue ;
}
2026-01-19 03:38:51 +00:00
if ( validateOnly ) {
registry . plugins . push ( record ) ;
2026-01-19 21:13:51 -06:00
seenIds . set ( pluginId , candidate . origin ) ;
2026-01-19 03:38:51 +00:00
continue ;
}
2026-01-11 12:11:12 +00:00
if ( typeof register !== "function" ) {
2026-01-19 00:15:15 +00:00
logger . error ( ` [plugins] ${ record . id } missing register/activate export ` ) ;
2026-03-02 21:31:18 +00:00
pushPluginLoadError ( "plugin export missing register/activate" ) ;
2026-01-11 12:11:12 +00:00
continue ;
}
const api = createApi ( record , {
config : cfg ,
pluginConfig : validatedConfig.value ,
2026-03-05 18:15:54 -05:00
hookPolicy : entry?.hooks ,
2026-03-15 16:17:24 -07:00
registrationMode ,
2026-01-11 12:11:12 +00:00
} ) ;
try {
const result = register ( api ) ;
2026-01-31 16:03:28 +09:00
if ( result && typeof result . then === "function" ) {
2026-01-11 12:11:12 +00:00
registry . diagnostics . push ( {
level : "warn" ,
pluginId : record.id ,
source : record.source ,
2026-01-14 14:31:43 +00:00
message : "plugin register returned a promise; async registration is ignored" ,
2026-01-11 12:11:12 +00:00
} ) ;
}
registry . plugins . push ( record ) ;
2026-01-19 21:13:51 -06:00
seenIds . set ( pluginId , candidate . origin ) ;
2026-01-11 12:11:12 +00:00
} catch ( err ) {
2026-02-22 21:18:53 +00:00
recordPluginError ( {
logger ,
registry ,
record ,
seenIds ,
pluginId ,
origin : candidate.origin ,
error : err ,
logPrefix : ` [plugins] ${ record . id } failed during register from ${ record . source } : ` ,
diagnosticMessagePrefix : "plugin failed during register: " ,
2026-01-11 12:11:12 +00:00
} ) ;
}
}
2026-03-16 07:52:08 +08:00
// 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 ) {
2026-01-18 02:12:01 +00:00
registry . diagnostics . push ( {
level : "warn" ,
message : ` memory slot plugin not found or not marked as memory: ${ memorySlot } ` ,
} ) ;
}
2026-02-19 15:13:34 +01:00
warnAboutUntrackedLoadedPlugins ( {
registry ,
2026-02-19 15:24:02 +01:00
provenance ,
2026-02-19 15:13:34 +01:00
logger ,
2026-03-12 15:31:31 +00:00
env ,
2026-02-19 15:13:34 +01:00
} ) ;
2026-01-11 12:11:12 +00:00
if ( cacheEnabled ) {
2026-03-12 15:31:31 +00:00
setCachedPluginRegistry ( cacheKey , registry ) ;
2026-01-11 12:11:12 +00:00
}
2026-03-16 07:52:08 +08:00
if ( shouldActivate ) {
activatePluginRegistry ( registry , cacheKey ) ;
}
2026-01-11 12:11:12 +00:00
return registry ;
}
2026-02-26 13:04:33 +01:00
function safeRealpathOrResolve ( value : string ) : string {
try {
return fs . realpathSync ( value ) ;
} catch {
return path . resolve ( value ) ;
}
}