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 > ;
feature(context): extend plugin system to support custom context management (#22201)
* feat(context-engine): add ContextEngine interface and registry
Introduce the pluggable ContextEngine abstraction that allows external
plugins to register custom context management strategies.
- ContextEngine interface with lifecycle methods: bootstrap, ingest,
ingestBatch, afterTurn, assemble, compact, prepareSubagentSpawn,
onSubagentEnded, dispose
- Module-level singleton registry with registerContextEngine() and
resolveContextEngine() (config-driven slot selection)
- LegacyContextEngine: pass-through implementation wrapping existing
compaction behavior for 100% backward compatibility
- ensureContextEnginesInitialized() guard for safe one-time registration
- 19 tests covering contract, registry, resolution, and legacy parity
* feat(plugins): add context-engine slot and registerContextEngine API
Wire the ContextEngine abstraction into the plugin system so external
plugins can register context engines via the standard plugin API.
- Add 'context-engine' to PluginKind union type
- Add 'contextEngine' slot to PluginSlotsConfig (default: 'legacy')
- Wire registerContextEngine() through OpenClawPluginApi
- Export ContextEngine types from plugin-sdk for external consumers
- Restore proper slot-based resolution in registry
* feat(context-engine): wire ContextEngine into agent run lifecycle
Integrate the ContextEngine abstraction into the core agent run path:
- Resolve context engine once per run (reused across retries)
- Bootstrap: hydrate canonical store from session file on first run
- Assemble: route context assembly through pluggable engine
- Auto-compaction guard: disable built-in auto-compaction when
the engine declares ownsCompaction (prevents double-compaction)
- AfterTurn: post-turn lifecycle hook for ingest + background
compaction decisions
- Overflow compaction: route through contextEngine.compact()
- Dispose: clean up engine resources in finally block
- Notify context engine on subagent lifecycle events
Legacy engine: all lifecycle methods are pass-through/no-op, preserving
100% backward compatibility for users without a context engine plugin.
* feat(plugins): add scoped subagent methods and gateway request scope
Expose runtime.subagent.{run, waitForRun, getSession, deleteSession}
so external plugins can spawn sub-agent sessions without raw gateway
dispatch access.
Uses AsyncLocalStorage request-scope bridge to dispatch internally via
handleGatewayRequest with a synthetic operator client. Methods are only
available during gateway request handling.
- Symbol.for-backed global singleton for cross-module-reload safety
- Fallback gateway context for non-WS dispatch paths (Telegram/WhatsApp)
- Set gateway request scope for all handlers, not just plugin handlers
- 3 staleness tests for fallback context hardening
* feat(context-engine): route /compact and sessions.get through context engine
Wire the /compact command and sessions.get handler through the pluggable
ContextEngine interface.
- Thread tokenBudget and force parameters to context engine compact
- Route /compact through contextEngine.compact() when registered
- Wire sessions.get as runtime alias for plugin subagent dispatch
- Add .pebbles/ to .gitignore
* style: format with oxfmt 0.33.0
Fix duplicate import (ControlUiRootState in server.impl.ts) and
import ordering across all changed files.
* fix: update extension test mocks for context-engine types
Add missing subagent property to bluebubbles PluginRuntime mock.
Add missing registerContextEngine to lobster OpenClawPluginApi mock.
* fix(subagents): keep deferred delete cleanup retryable
* style: format run attempt for CI
* fix(rebase): remove duplicate embedded-run imports
* test: add missing gateway context mock export
* fix: pass resolved auth profile into afterTurn compaction
Ensure the embedded runner forwards resolved auth profile context into
legacy context-engine compaction params on the normal afterTurn path,
matching overflow compaction behavior. This allows downstream LCM
summarization to use the intended provider auth/profile consistently.
Also fix strict TS typing in external-link token dedupe and align an
attempt unit test reasoningLevel value with the current ReasoningLevel
enum.
Regeneration-Prompt: |
We were debugging context-engine compaction where downstream summary
calls were missing the right auth/profile context in normal afterTurn
flow, while overflow compaction already propagated it. Preserve current
behavior and keep changes additive: thread the resolved authProfileId
through run -> attempt -> legacy compaction param builder without
broad refactors.
Add tests that prove the auth profile is included in afterTurn legacy
params and that overflow compaction still passes it through run
attempts. Keep existing APIs stable, and only adjust small type issues
needed for strict compilation.
* fix: remove duplicate imports from rebase
* feat: add context-engine system prompt additions
* fix(rebase): dedupe attempt import declarations
* test: fix fetch mock typing in ollama autodiscovery
* fix(test): add registerContextEngine to diffs extension mock APIs
* test(windows): use path.delimiter in ios-team-id fixture PATH
* test(cron): add model formatting and precedence edge case tests
Covers:
- Provider/model string splitting (whitespace, nested paths, empty segments)
- Provider normalization (casing, aliases like bedrock→amazon-bedrock)
- Anthropic model alias normalization (opus-4.5→claude-opus-4-5)
- Precedence: job payload > session override > config default
- Sequential runs with different providers (CI flake regression pattern)
- forceNew session preserving stored model overrides
- Whitespace/empty model string edge cases
- Config model as string vs object format
* test(cron): fix model formatting test config types
* test(phone-control): add registerContextEngine to mock API
* fix: re-export ChannelKind from config-reload-plan
* fix: add subagent mock to plugin-runtime-mock test util
* docs: add changelog fragment for context engine PR #22201
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-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-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).
let jitiLoader : ReturnType < typeof createJiti > | null = null ;
const getJiti = ( ) = > {
if ( jitiLoader ) {
return jitiLoader ;
}
const pluginSdkAlias = resolvePluginSdkAlias ( ) ;
const aliasMap = {
. . . ( pluginSdkAlias ? { "openclaw/plugin-sdk" : pluginSdkAlias } : { } ) ,
. . . resolvePluginSdkScopedAliasMap ( ) ,
} ;
2026-03-16 20:58:58 -04:00
jitiLoader = createJiti ( import . meta . url , buildPluginLoaderJitiOptions ( aliasMap ) ) ;
2026-03-15 20:02:24 -07:00
return jitiLoader ;
} ;
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" ) ;
}
const runtimeModule = getJiti ( ) ( runtimeModulePath ) as {
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" &&
! (
capability === "commands" &&
( record . bundleFormat === "claude" || record . bundleFormat === "cursor" )
) &&
! ( capability === "hooks" && record . bundleFormat === "codex" ) ,
) ;
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-02-26 13:04:33 +01:00
mod = getJiti ( ) ( 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 ) ;
}
}