跳到主要内容

数据截至 (上游 commit 8d6cbee1b527)

插件化内核:通道、模型商、工具都是扩展

30 秒导读: OpenClaw 把「接哪个 IM」「用哪个模型商」「有哪些工具」全部外包给插件。内核自己只做四件事——扫出候选、读清单、按需导入、把注册结果记进一张表。这一章讲清楚:为什么仓库里躺着 149 个插件,而网关启动时可能一个都不导入。

本章不覆盖:通道运行时的消息语义(见 03-inbound-and-sessions)、工具执行与沙箱策略(见 06-tools-skills-sandbox)。网关进程形态见 01-gateway-control-plane


1. 这是什么(零基础也能懂)

一句话定义: OpenClaw 的插件内核是一套「先读元数据、后导入代码」的扩展装载机制,让通道(Telegram/Discord/…)、模型商(Anthropic/OpenAI/…)、工具、命令、HTTP 路由都以同一种方式接进来。

先看规模

克隆里 extensions/ 下的实际情况:

项目数量说明
子目录总数151find extensions -maxdepth 1 -type d
openclaw.plugin.json 的插件149其余是共享辅助包
Plugin SDK 对外子路径312package.jsonexports 共 314 条,其中 312 条是 ./plugin-sdk*

这个数量级的扩展如果全部在启动时导入,进程会被拖垮。内核的全部设计张力就在这里。

解决什么问题

假设你要让一个 AI agent 同时接 Telegram、Discord、飞书,还要能在 Anthropic 和 OpenAI 之间切换,再挂几个自定义工具。最笨的做法是把这些代码全写进主程序,于是主程序里到处是 if (channel === "telegram")

OpenClaw 的做法是反过来:主程序不知道 "telegram" 这个词。它只知道「有一个插件声明自己拥有 channels: ["telegram"]」,并在真的需要 telegram 通道时,才去把那个插件的代码导进来。

一个最小插件长什么样

Telegram 插件的清单(extensions/telegram/openclaw.plugin.json):

{
"id": "telegram",
"icon": "https://cdn.simpleicons.org/telegram",
"activation": { "onStartup": false },
"channels": ["telegram"],
"configSchema": { "type": "object", "additionalProperties": false, "properties": {} }
}

入口文件同样很薄——它不 import 任何 Telegram 代码(extensions/telegram/index.ts:5,defineBundledChannelEntry):

export default defineBundledChannelEntry({
id: "telegram",
importMetaUrl: import.meta.url,
plugin: { specifier: "./channel-plugin-api.js", exportName: "telegramPlugin" },
runtime: { specifier: "./runtime-setter-api.js", exportName: "setTelegramRuntime" },
// …secrets / accountInspect 同理,另加 registerFull: registerTelegramMiniApp
});

注意 specifier字符串,不是 import 语句。grammy(Telegram SDK)那一大坨依赖,要等到内核真的调用 loadChannelPlugin() 时才会被拉起来。这就是贯穿全章的主线。

一句话直觉

清单像门口的名牌,代码像屋里的家具。 内核走一圈只看名牌,知道谁住哪、会做什么;只有真要用某个人的时候,才推门进去搬家具。


2. 顶层全景(它大概怎么转)

怎么读这张图:从左到右是一次插件加载的四段路,前三段都不执行插件代码

磁盘上的目录 元数据 代码 能力表
┌───────────────┐ ┌───────────────┐ ┌──────────────┐ ┌──────────────┐
│ ① 发现 │ │ ② 清单登记 │ │ ③ 按需导入 │ │ ④ 注册表 │
│ 扫三类根目录 │─────▶│ 解析+校验+去重│─────▶│ 只导入被选中 │─────▶│ 登记通道/工具│
│ 产出「候选」 │ │ 产出「清单表」│ │ 的那几个模块 │ │ /模型商/路由 │
└───────────────┘ └───────────────┘ └──────────────┘ └──────────────┘
discovery.ts manifest-registry.ts loader-runtime-load.ts registry.ts
│ │ │ │
│ │ │ ▼
│ └──── 内核大多数查询到这里就够了 ───┐ ┌──────────┐
│ (启动计划/配置校验/安装状态) └──▶│ hook 总线│
▼ └──────────┘
ClawHub / marketplace / npm ──安装期安全扫描──▶ 落到 ~/.openclaw/extensions

部件一句话职责

部件干什么在哪个文件
发现(discovery)扫描三类根目录,产出 PluginCandidate[] 和诊断src/plugins/discovery.ts:1507 discoverOpenClawPlugins
清单(manifest)解析 openclaw.plugin.json,强制 id + configSchemasrc/plugins/manifest.ts:128 loadPluginManifest
清单登记表把候选 × 清单合成 PluginManifestRecord[],做兼容性与同 id 去重src/plugins/manifest-registry.ts:1013 loadPluginManifestRegistryCore
加载器(loader)决定谁该导入、以什么模式导入、失败怎么降级src/plugins/loader-runtime-load.ts:66 loadOpenClawPlugins(src/plugins/loader.ts 只是稳定门面)
注册表(registry)提供 api.registerXxx(...) 系列,把注册结果收进一张大表src/plugins/registry.ts:32 createPluginRegistry
hook 总线生命周期钩子的注册、排序、超时、失败裁决src/plugins/hooks.ts:307 createHookRunner
启动计划只从清单元数据算出「网关启动需要哪些插件 id」src/plugins/gateway-startup-plugin-plan.ts:52 resolveGatewayStartupPluginPlanFromRegistry
安装侧ClawHub / marketplace / npm / 本地目录安装 + 安全裁决src/plugins/install-package.tsinstall-npm.tsclawhub.tsmarketplace.ts

主线走一遍(高层)

  1. 网关启动,先算「启动计划」:遍历清单,看谁 activation.onStartup === true、谁的通道被配置了(shouldConsiderForGatewayStartup,src/plugins/gateway-startup-plugin-config.ts:239)。
  2. 把算出来的 id 列表当作 onlyPluginIds 传给加载器。
  3. 加载器只对这几个 id 走「导入 → 调 register(api)」;其余插件只留一条元数据记录。
  4. 插件在 register 里调 api.registerChannel(...) / api.registerTool(...) 等,结果落进 PluginRegistry
  5. 之后内核所有「我需要一个 X 能力」的查询,都先看活动注册表,不命中再回到清单缩小范围、定向加载。

3. 清单契约:openclaw.plugin.json

它要解决的小问题

内核想知道「这个插件能干什么」,但又不想为此执行它的代码——执行代码就意味着导入整棵依赖树。清单就是这个「不执行也能回答」的答案文件。

文件名是硬编码常量(src/plugins/manifest.ts:28 PLUGIN_MANIFEST_FILENAME),上限 256 KB(同文件 :30 MAX_PLUGIN_MANIFEST_BYTES)。

只有两个字段是必填的

loadPluginManifest(src/plugins/manifest.ts:128)里的硬校验:

字段缺了会怎样
id直接返回 plugin manifest requires id,候选被丢弃
configSchema直接返回 plugin manifest requires configSchema

configSchema 之所以强制,是因为加载器在导入模块之前就要用它校验用户配置(validatePluginConfig,src/plugins/loader-shared.ts:201)。「空对象 + additionalProperties: false」的写法有专门快路径(isEmptyPluginConfigJsonSchema,loader-shared.ts:236):等价于「我不接受任何配置」。

常用字段分组

PluginManifest 类型有 48 个字段(src/plugins/manifest-types.ts:338),按用途分成四组:

组别代表字段谁在用
身份id / name / version / icon / legacyPluginIds注册表、CLI 列表、marketplace 卡片
能力所有权channels / providers / cliBackends / skills / contracts反查「谁拥有 X」,用于定向加载
启用策略enabledByDefault / enabledByDefaultOnPlatforms / requiresPlugins / autoEnableWhenConfiguredProviders加载器算 enable state
冷元数据modelCatalog / modelPricing / providerAuthChoices / setup / toolMetadata在插件运行时未加载时也能回答的问题

「冷元数据」这一组是内核不膨胀的关键。 比如模型价格表、模型 id 归一化规则、provider 支持哪些登录方式——这些以前很容易被写进核心的 switch,现在都由清单声明,核心只写通用读取逻辑。

activation:什么时候把我叫醒

PluginManifestActivation(src/plugins/manifest-types.ts:156)是一组「触发条件」声明:

字段含义备注
onStartup网关启动时必须导入Telegram 写 false——它靠通道被配置来触发
onProviders这些 provider id 被引用时把我算进计划只是计划元数据,真实行为仍在 register()
onChannels这些通道 id 被引用时
onCommands / onRoutes命令 / 路由种类触发
onConfigPaths配置里出现这些路径时hasConfiguredActivationPath,src/plugins/gateway-startup-plugin-config.ts:291
onCapabilities粗粒度提示:provider / channel / tool / hook(manifest-types.ts:154)注释明确写了「优先用更窄的所有权元数据」

启动判定的实际读取只有一行(src/plugins/gateway-startup-plugin-config.ts:246):

if (params.manifest?.activation?.onStartup === true) {
return true;
}

其余分支靠「通道被配置」「memory slot 选中」「context-engine slot 选中」等更具体的信号,而不是插件自己喊「我很重要」。

contracts:声明才能注册

PluginManifestContracts(src/plugins/manifest-types.ts:451,22 个字段)是一张「我打算注册哪些能力」的白名单,包含 toolsembeddingProvidersspeechProviderswebSearchProvidersgatewayMethodDispatch 等。

它不是文档,是运行期强制的。注册工具时先查这张表(src/plugins/registry-registrars-tools-hooks.ts:231-237):

const declaredNames = normalizePluginToolContractNames(record.contracts);
if (declaredNames.length === 0) {
pushDiagnostic({ level: "error", /* … */
message: "plugin must declare contracts.tools before registering agent tools" });
return;
}

声明了清单但没覆盖到具体名字也会被拒(同文件 :255,"plugin must declare contracts.tools for: …")。好处是双向的:内核可以在不加载插件的情况下知道「谁提供了 web-search 能力」,同时插件也不能偷偷注册没声明过的东西。


4. 发现:三类来源、扫描顺序与冲突裁决

三类根目录

resolvePluginSourceRoots(src/plugins/roots.ts,经 src/plugins/discovery.ts:1491 调用)只认三个位置:

来源路径origin 标签
内置随包发布的 bundled 目录(源码检出时是 extensions/)bundled
全局安装<配置目录>/extensions(即 ~/.openclaw/extensions)global
工作区<workspace>/.openclaw/extensionsworkspace

再加一类不属于「根目录」的:用户在配置里手写的 plugins.load.paths,origin 标为 config

PluginOrigin 就这四个值(src/plugins/plugin-origin.types.ts),它同时是信任等级的载体。

扫描顺序:先私有后共享

discoverOpenClawPlugins(src/plugins/discovery.ts:1507)把扫描拆成两个阶段,再合并去重:

阶段 A「scoped」(与当前工作区绑定)
① plugins.load.paths 里的每条路径 origin=config
② <workspace>/.openclaw/extensions origin=workspace

阶段 B「shared」(全机器共享) │
③ bundled 源码 overlay(开发用,带 warn) │
④ bundled 根目录 │ 合并时按 source 去重,
⑤ 源码检出的 extensions/(排除已扫过的子目录) │ 阶段 A 先入者胜
⑥ 安装记录里登记的路径 origin=global
⑦ ~/.openclaw/extensions 自动发现 origin=global
│ │
└──────────┬─────────────────┘

mergeDiscoveryResult(去重) + 缺失依赖诊断

合并逻辑在 mergeDiscoveryResult(discovery.ts:410,两个阶段的调用点 :1712-1713);缺 requiresPlugins 依赖的会被补一条诊断(addMissingRequiredPluginDiagnostics,discovery.ts:448,调用点 :1714)。

一处刻意的克制值得注意:工作区不做递归全扫。 只扫 .openclaw/extensions 这一层,注释写明原因:递归扫整个工程会把随便一个文件夹当成插件候选,产生一堆「plugin manifest not found」噪音(discovery.ts:1547-1548)。

候选长什么样

PluginCandidate(src/plugins/discovery.ts:79)是「还没读清单」的粗坯:入口文件 source、根目录 rootDirorigin、可选的 setupSource(轻量 setup 入口)、packageManifest 等。清单信息要到下一步才补齐。

同一个 id 出现多次怎么办

先由清单登记表规范化,再由加载器按信任排名排序。排名函数很直白(src/plugins/loader-provenance.ts:166-188,数字小者优先):

排名理由
0origin=config用户在配置里显式点名,最高优先
1开发源码根里的 bundled本地改内置插件时以源码为准
2有显式安装记录的 global用户主动装的
3其余 bundled「内置 id 保留,除非操作员显式覆盖」
4workspace
5其余

排序由 compareDuplicateCandidateOrder(loader-provenance.ts:192)完成;加载循环里第一个胜出者占住 id,后来的同 id 候选直接记成 disabled,错误写「overridden by <origin> plugin」(src/plugins/loader-runtime-candidate.ts:134)。

发现阶段的安全闸

候选在入表前会被路径检查拦一道(discovery.ts:127 CandidateBlockReason):

拒绝原因含义
source_escapes_root入口文件(经 realpath)跑到插件根目录外面
path_stat_failedstat 失败
path_world_writable目录/文件全局可写
path_suspicious_ownership属主不是当前用户

另外托管安装目录还会拒绝硬链接文件(shouldRejectHardlinkedPluginFiles,src/plugins/hardlink-policy.ts;使用点如 src/plugins/loader-cli-registry.ts:218)——防止用硬链接绕过根目录边界。


5. 加载:六种模式、缓存、重入保护与失败降级

5.1 不是「加载 / 不加载」,而是六档

PluginRegistrationMode(src/plugins/plugin-registration.types.ts:398-405)是给插件看的公开标签:

模式什么时候用插件该做什么
full正常激活全量注册
discovery只要一份快照,不改全局状态注册能力,但不做全局副作用
tool-discovery只为列出工具只跑 registerFull 里的工具部分
setup-only引导/配置向导只加载 setup 入口
setup-runtime监听前的通道预热setup 入口 + 运行时 setter
cli-metadata只为注册 CLI 命令描述只跑 registerCliMetadata

内部真正的分派表是另一份结构 PluginRegistrationPlan(src/plugins/loader-registration-plan.ts:7-17),把模式解码成四个布尔量:loadSetupEntryloadSetupRuntimeEntryrunRuntimeCapabilityPolicyrunFullActivationOnlyRegistrations。模式是标签,plan 才是唯一事实源——转换函数 resolvePluginRegistrationPlan 在同文件 :34

注册表侧对应地把模式解码成能力位(resolvePluginRegistrationCapabilities,src/plugins/registry-state.ts:29):哪些 registerXxx 在该模式下真正生效。「setup 时不要真的把通道挂上去」这条规则,只在一个地方表达。

5.2 主循环

loadOpenClawPlugins(src/plugins/loader-runtime-load.ts:66)的骨架:

归一化 onlyPluginIds(:81)──▶ 空集合?→ 返回空注册表(仍会激活,:87)

算缓存 key ──▶ 命中缓存?→ 恢复全局态 + 激活 + 直接返回(:103-106)
│ 未命中
beginLoad(cacheKey)(:117) ← 重入检测在这里

发现 + 清单登记(:164)──▶ 按信任排名排序候选

┌─────每个候选─────────────────────────────────┐
│ 不在 onlyPluginIds → skip │
│ id 已被占 → 记 disabled「overridden by …」 │
│ 算 enable state → 不启用则记 disabled │
│ 没有 configSchema → 记 error(validation) │
│ 配置不合 schema → 记 error(validation) │
│ shouldLoadModules=false → 只写元数据快照 │
│ 打开入口文件(根目录边界检查) │
│ import 模块 ────────── 抛错 → 记 error(load)│
│ 取 register/activate ── 没有 → 记 error │
│ 调 register(api) │
│ └ 抛错 → 回滚全局副作用 + 还原记录 │
└──────────────────────────────────────────────┘

写缓存 + activatePluginRegistry(挂 hook runner)(:287)

finally: finishLoad(cacheKey)

5.3 缓存与重入保护

缓存是「按 load options 算 key 的 LRU」,上限 128 条(MAX_PLUGIN_REGISTRY_CACHE_ENTRIES,src/plugins/loader-cache.ts:7)。真正有意思的是重入保护:

beginLoad(cacheKey: string): void {
if (this.#inFlightLoads.has(cacheKey)) {
throw new PluginLoadReentryError(cacheKey);
}
this.#inFlightLoads.add(cacheKey);
}

依据:src/plugins/loader-cache-state.ts:48-53

为什么需要它?插件的 register() 里如果不小心调用了某个「顺手加载插件注册表」的辅助函数,就会递归回到同一次加载,产生半初始化的注册表。这里选择硬报错而不是静默返回半成品。

配套的软策略在 resolveRuntimePluginRegistry(src/plugins/loader-runtime-registry.ts:22):辅助路径发现同 key 正在加载中,就返回 undefined 让调用方走降级,把硬错误留给直接调用者。

缓存的清理入口在 src/plugins/loader-cache.ts:clearPluginRegistryLoadCache(:21)清掉缓存的注册表,resolvePluginRegistryLoadCacheKey(:26)与 isPluginRegistryLoadInFlight(:30)是配套的查询。

5.4 失败降级:三个阶段,三种后果

失败不会让整个网关挂掉,而是记进那个插件自己的 PluginRecord.failurePhase(src/plugins/loader-records.ts:156:178):

failurePhase触发点处理
validation缺 configSchema、配置不合 schema、id 与导出不一致记 error,继续下一个插件(loader-records.ts:124loader-shared.ts:280)
loadimport 抛错、入口路径越界、setup 入口坏了记 error + 诊断,继续(recordPluginError,loader-records.ts:149)
registerregister(api) 抛错先回滚再记错:rollbackPluginGlobalSideEffects(id, record)(src/plugins/loader-runtime-candidate.ts:552;注册表记录的拍快照/还原在 src/plugins/registry.ts:19-28,clonePluginRecord / restorePluginRecord)

这样「注册到一半炸了」不会留下半截通道或半截工具——非激活快照在缓存激活前是私有的,回滚同时还原两边(loader-runtime-candidate.ts:540 的注释)。

只有显式传 throwOnLoadError: true 时,才会在最后抛 PluginLoadFailureError(src/plugins/loader-shared.ts:185,抛出点 :357),并把整个 registry 挂在异常上供调用方检查。

recordPluginError 里还有一个体贴的细节:发现错误信息里含 api.registerHttpHandler ... is not a function,就自动改写成迁移提示,告诉作者改用 api.registerHttpRoute(...)(loader-records.ts:169-170)。

5.5 连 runtime 本身都是惰性的

插件拿到的 api.runtime 是一个 Proxy(src/plugins/loader-module-runtime.ts:224)。只有真的读某个属性时,才会去解析并创建 PluginRuntime:

return new Proxy({} as PluginRuntime, {
get(_target, prop, receiver) {
// Instance-bound surfaces are complete runtime objects. Keep them direct so
// the first Gateway call does not materialize the broad plugin runtime graph.
if (prop === "gateway" || prop === "nodes" || prop === "subagent") {}
return Reflect.get(resolveRuntime(), prop, receiver); // 首次访问才构造
},
// …
});

注释写明动机(loader-module-runtime.ts:226-227):让「发现/跳过插件」的启动路径不必急切拉起整棵插件运行时依赖图。


6. 注册表:一张表登记所有能力

6.1 它长什么样

PluginRegistry(src/plugins/registry-types.ts:527-597)就是一堆按能力分类的数组(60 多个桶):

分组字段(节选)
通道channelschannelSetups
模型与推理providersmodelCatalogProviderscliBackendsagentHarnesses
工具与中间件toolstoolMetadataagentToolResultMiddlewarestrustedToolPolicies
钩子hookstypedHooks
网关面gatewayHandlersgatewayMethodDescriptorshttpRoutesservices
媒体与检索speechProvidersimageGenerationProviderswebSearchProviderswebFetchProvidersembeddingProviders
会话面sessionExtensionssessionSchedulerJobssessionActionscommands
元信息plugins(每个插件的 PluginRecord)、diagnostics

装配函数 createPluginRegistry(src/plugins/registry.ts:32)只做生命周期接线:状态在 registry-state.ts、各领域校验与变更在 registry-registrars-*.ts、API 面在 registry-api.ts

插件侧看到的是 OpenClawPluginApi(src/plugins/plugin-api.types.ts:175),上面挂着 50 多个 registerXxx。几个关键签名:

API用途定义
registerTool注册 agent 工具(工厂或实例)plugin-api.types.ts:204
registerHook注册生命周期钩子plugin-api.types.ts:208
registerHttpRoute注册网关 HTTP 路由plugin-api.types.ts:213
registerChannel注册消息通道plugin-api.types.ts:222
registerGatewayMethod注册网关 RPC 方法plugin-api.types.ts:230
registerProvider注册文本推理 providerplugin-api.types.ts:274
registerCommand注册绕过 LLM 的斜杠命令plugin-api.types.ts:312
registerContextEngine注册上下文引擎(独占槽位)plugin-api.types.ts:314

6.2 注册期守卫:冲突不靠约定,靠代码

每个 registerXxx 都不是简单 push,而是先过一遍规则。挑四个有代表性的:

能力守卫规则依据
工具必须先在 contracts.tools 里声明,未声明的名字被拒registry-registrars-tools-hooks.ts:231-237:255
通道同 channel id 已被别的插件占用 → error,并把该插件标记进 pluginsWithChannelRegistrationConflict(后续工具注册也一并跳过)registry-registrars-network.ts:353-359 + registry-registrars-tools-hooks.ts:228
网关方法撞上核心方法名或已注册方法 → error;命中保留命名空间(config.* / exec.approvals.* / wizard.* / update.*)时强制降为 operator.admin 并 warnregistry-registrars-network.ts:72-77
HTTP 路由路径重叠但 auth 模式不同 → 直接拒绝;完全同路径需 replaceExisting 且同属主registry-registrars-network.ts:179-180:213

registerCodexAppServerExtensionFactory 更严:有专门的 noop 兜底与装配面(src/plugins/api-builder.ts:146:265),真实注册在 src/plugins/captured-registration.ts:231。这是「内置有特权、但特权也要写进清单」的一个样板。

6.3 内核怎么按能力反查

注册表只在插件加载后才有内容,但内核经常要在「什么都还没加载」时回答问题。解法是三级回退:

要一个 X 能力

① 活动运行时注册表里有吗? ← getLoadedRuntimePluginRegistry()
│ 没有
② 查清单元数据:谁声明拥有 X? ← contracts / providers / channels / toolMetadata
│ 得到一小撮 plugin id
③ 只加载这几个 id,再查注册表 ← loadOpenClawPlugins({ onlyPluginIds })

三个典型实现:

场景函数位置
媒体/语音/嵌入等能力 providerresolvePluginCapabilityProvidersrc/plugins/capability-provider-runtime.ts:502
「哪个插件拥有这个 provider id」resolveOwningPluginIdsForProvidersrc/plugins/providers.ts:608
agent 可用工具resolvePluginToolssrc/plugins/tools.ts:1228

以能力 provider 为例,第 ② 步先算出 runtimePluginIds,为空就直接返回 undefined——一个模块都不导入(capability-provider-runtime.ts:530:531-532);不为空才构造只含这几个 id 的 loadOptions 去定向加载。

工具侧还留了一条冷启动补救路径:ensureStandalonePluginToolRegistryLoaded(tools.ts:1225)——配置里通过 plugins.load.paths 挂的插件不属于任何活动通道注册表,第一次查不到工具时会触发一次独立加载再重试。

这一节是「内核不膨胀」的落点。 内核代码里几乎没有插件 id 字面量;能力查询走的是「清单声明 → 定向加载 → 注册表」这条通用管道。少数例外被明确命名,比如 memory dreaming 的默认引擎 DEFAULT_MEMORY_DREAMING_PLUGIN_ID = "memory-core"(src/memory-host-sdk/dreaming.ts:29)。


7. hook 总线:插件怎么插进生命周期

7.1 有哪些钩子

PluginHookHandlerMap(src/plugins/hook-types.ts:1176)是钩子名到签名的全表,共 42 个。按语义分三类:

类别返回值语义代表钩子
观察型返回 void,只能看model_call_started / model_call_ended / llm_input / llm_output / message_received / message_sent / session_start / session_end / after_tool_call / agent_end
改写型返回部分结果并被合并before_model_resolve / before_prompt_build / agent_turn_prepare / before_agent_finalize / message_sending / reply_payload_sending
裁决型返回决定并短路before_tool_call / before_agent_run / before_install / inbound_claim / before_dispatch

按触发点则大致对应四个阶段:

网关入站 / 会话agent 回合出站
gateway_startinbound_claimbefore_agent_run(裁决)reply_dispatch
gateway_stopsession_startbefore_model_resolvereply_payload_sending
cron_changedbefore_dispatchbefore_prompt_buildmessage_sending
before_installmessage_receivedbefore_tool_call(裁决)message_sent
session_endafter_tool_call
before_compaction / after_compaction
before_agent_finalize / agent_end

7.2 排序、超时与失败策略

排序: 同名钩子按 priority 降序执行,未声明按 0 算(src/plugins/hooks.ts:284,注释在 :254):

.toSorted((a, b) => (b.priority ?? 0) - (a.priority ?? 0));

改写型钩子的合并规则明确写着「先定义者胜」,所以高优先级插件的覆盖不会被后面的覆盖掉(hooks.ts:373hooks.ts:447)。裁决型钩子一旦做出决定,直接跳过剩下的处理器并打日志(hooks.ts:746)。

失败策略: 默认 fail-open(捕获异常继续),三个钩子例外(src/plugins/hook-runner-global.ts:46-50):

钩子策略为什么
before_agent_runfail-closed它是 agent 运行的准入闸,坏了不能默认放行
before_installfail-closed安装准入,同理
before_tool_callfail-closed工具调用准入,同理

超时: 有默认预算表。观察型钩子里 agent_end 30 秒、before_compaction / after_compaction 各 30 秒(hooks.ts:160-176);改写/裁决型里 before_agent_runbefore_installbefore_tool_callbefore_agent_finalizebefore_prompt_buildmessage_sendingreply_payload_sendingresolve_exec_env 各 15 秒(hooks.ts:181-196)。这些预算来自真实事故:一个卡住的插件曾把整条 agent 流水线堵死。

7.3 全局 runner 为什么是「组合视图」

initializeGlobalHookRunner(src/plugins/hook-runner-global.ts:33)每次注册表激活都会被调用,但 runner 实例只创建一次,钩子则在每次派发时现查。

文件顶部的注释(hook-runner-global.ts:1-11)解释了这个设计:runner 是单例,但每次 dispatch 都从「当前请求作用域注册表或进程根」现查钩子——这样中途的小范围激活(harness、memory ensure)不会把 runner 钉死在一个窄注册表上,初始化之后推送的钩子也能立即生效。组合视图本身由 createLiveHookRegistryFacade 提供(src/plugins/hook-runner-global-state.ts)。


8. 对外契约:plugin-sdk 与惰性 specifier

8.1 SDK 的形状

插件只能通过 openclaw/plugin-sdk/* 进核心。package.jsonexports 里共 314 个条目,其中 312 个是 ./plugin-sdk* 子路径。这个「宁可多开窄路口,也不开一个大门」的取向,是为了让每个入口的模块加载成本可控。

packages/plugin-package-contract 则是外部插件包的最小契约,只要求两个字段(packages/plugin-package-contract/src/index.ts:29-32 EXTERNAL_CODE_PLUGIN_REQUIRED_FIELD_PATHS):

字段作用
openclaw.compat.pluginApi声明兼容的 plugin API 版本区间
openclaw.build.openclawVersion打包时用的 OpenClaw 版本

校验入口是 validateExternalCodePluginPackageJson(同文件 :90),缺字段会变成结构化 issue 而不是崩溃。清单登记表在装配记录时会拿这些做兼容性判断:plugin API 区间不满足就记 warn 并跳过加载(src/plugins/manifest-registry.ts:1167,satisfiesPluginApiRange)。

8.2 defineBundledChannelEntry:惰性写法的核心

回看 §1 的 Telegram 入口——它传的是 { specifier, exportName } 这种模块引用描述,而不是 import。defineBundledChannelEntry(src/plugin-sdk/channel-entry-contract.ts:485)把每个描述包成一个 loader 闭包:

const loadChannelPlugin = (options?: BundledEntryModuleLoadOptions) =>
loadBundledEntryExportSync<TPlugin>(importMetaUrl, plugin, options);

依据:channel-entry-contract.ts:502-503(loadBundledEntryExportSync 定义在 :462)。返回的契约对象里,loadChannelPlugin / loadChannelSecrets / loadChannelAccountInspector / setChannelRuntime 全是这种「调了才导入」的函数。

register(api) 则按模式分叉(channel-entry-contract.ts:550 起):

registrationMode做什么
cli-metadata只跑 registerCliMetadata,完全不碰通道模块(:550)
tool-discovery只跑 registerFull(:556)
其他loadChannelPlugin()api.registerChannel(...)setChannelRuntime(api.runtime);discovery 到此为止,full 再跑 CLI 元数据与 registerFull

Telegram 的 channel-plugin-api.ts 只有三行,注释直说动机:「把 bundled channel 入口的 import 面收窄,别让引导/发现路径拖进宽泛的 Telegram API 桶」(extensions/telegram/channel-plugin-api.ts:1-4)。

8.3 specifier 解析:dist 优先,源码兜底

resolveBundledEntryModulePath(channel-entry-contract.ts:317)按候选列表逐个尝试打开(resolveBundledEntryModuleCandidates 生成,:317-363 的循环逐个过 openRootFileSync 边界检查):相对入口目录解析 specifier(.js → 同时试 .ts),外加给源码检出留的后门变体——发布包先从 dist 解析,解析不到再回落到源码树,让本地开发不必每次先 build。可以用环境变量 OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK 关掉(channel-entry-contract.ts:144)。

加载结果按入口边界信息缓存(entryBoundaryInfoCache,channel-entry-contract.ts:183-203),所以同一个 sidecar 模块只会被真正解析一次。

8.4 setup 入口:更轻的那条路

defineBundledChannelSetupEntry(channel-entry-contract.ts:587)是给引导/迁移场景的更轻入口。Telegram 的 setup-entry.ts 只有 14 行,只声明 setup 插件与 runtime setter 等少量 specifier。

设计意图:setup 流程只需要 setter,不需要把完整通道入口拉起来。加载器侧对应的是 setup-only / setup-runtime 两档 plan(见 §5.1)。

package.jsonopenclaw 块补充清单没覆盖的展示层元数据:extensions(入口列表)、setupEntrysetupFeatureschannel(标签、文档路径、是否支持 markdown、原生命令默认值、configuredState 探针)。见 extensions/telegram/package.json


9. 外部插件生态:安装、更新、卸载与信任裁决

9.1 四条安装入口

入口函数位置
本地目录installPluginFromDirsrc/plugins/install-package.ts:420
npm 包installPluginFromNpmSpecsrc/plugins/install-npm.ts:47
归档installPluginFromArchivesrc/plugins/install-package.ts:360
ClawHubinstallPluginFromClawHubsrc/plugins/clawhub.ts:1223
marketplace 清单installPluginFromMarketplacesrc/plugins/marketplace.ts:1280

(src/plugins/install.ts 现在只是再出口门面。)marketplace 是「一份列出多个插件及其来源的清单」,来源可以是 path / github / git / git-subdir / url(marketplace.ts:45-51),最终仍然落到上面那几条真实安装路径。

9.2 ClawHub 安装流水

解析 spec ──▶ 拉包详情 ──▶ 选兼容版本 ──▶ 校验包元数据

├─ 非官方包 → ensureClawHubPackageTrustAcknowledged(需要用户确认风险)

├─ 既无 sha256 也无 verification → 直接失败(ARTIFACT_UNAVAILABLE)

下载归档 ──▶ 完整性校验(失败一律 ARCHIVE_INTEGRITY_MISMATCH)

installPluginFromArchive(带来源/权威性标记)

写安装记录(registry URL、package、family、channel、version、integrity、resolvedAt)

依据:installPluginFromClawHub(clawhub.ts:1223);信任分岔在 clawhub.ts:1311-1323(officialClawHubPackage 为真则跳过风险确认,否则走 ensureClawHubPackageTrustAcknowledged);ARTIFACT_UNAVAILABLE:1343;完整性校验失败返回 ARCHIVE_INTEGRITY_MISMATCH(:686 起多处);归档安装调用点 :1459,权威性落库 :1478

值得注意的是官方/第三方的分界:officialClawHubPackage(clawhub.ts:1311)为真时默认跳过风险确认弹窗,第三方源必须走 onClawHubRisk 回调让人点头。信任裁决的结果会被写进安装记录,留痕。

9.3 安装期安全扫描的三层

scanPackageInstallSourceRuntime(src/plugins/install-security-scan.runtime.ts:983)的顺序固定:

做什么依据
① 依赖树边界扫描validatePackageDependencyBoundaries:BFS 整棵依赖树,软链越出根目录/深度超限/目录数超限都直接抛错;node_modules 里的 peer 软链走白名单检查install-security-scan.runtime.ts:499-540
② 操作员安装策略runOperatorInstallPolicy:按来源类型/权威性/是否联网评估,blocked 直接返回同文件 :720,编排 :1000-1043
before_install 钩子交给插件生态自己再裁一刀(fail-closed)同文件 :1046-1063(runBeforeInstallHook)

被信任的来源可以走 shouldBypassOpenClawInstallFriction 只跑第 ② 层(:701,判定调用 :1033),例如源码链接的官方安装。

结果类型很克制(InstallSecurityScanResult,:111-117):只有 blocked?: { code, reason },不返回大而全的报告对象。

9.4 更新与卸载

更新入口是 updateNpmInstalledPlugins(src/plugins/update-installed.ts:87),结果状态见 PluginUpdateOutcome(src/plugins/update-source.ts:69):updated / unchanged / skipped / error,并有专门的完整性漂移检查参数(PluginUpdateIntegrityDriftParams,update-source.ts:85)。

卸载是「先出计划、再执行」两段式:

  • planPluginUninstall(src/plugins/uninstall.ts:351)算出要动哪些东西;
  • 目录删除等执行面在 applyPluginUninstallDirectoryRemoval(uninstall.ts:517)等函数里。

要动的东西被枚举成九项(uninstall.ts:39-49 UNINSTALL_ACTION_LABELS,顺序在 :51-61):

动作含义
entryplugins.entries.<id> 配置项
install安装记录
allowlist / denylist允许/拒绝名单条目
loadPathplugins.load.paths 里的路径
memorySlot / contextEngineSlot独占槽位(会重置回默认值)
channelConfig该插件拥有的通道配置键
directory磁盘目录

把「卸载」拆成这样一张清单,好处是可以在真正删之前把影响面完整打印给用户看(formatUninstallActionLabels,uninstall.ts:63;formatUninstallSlotResetPreview,:73)。


10. 巧妙之处(可借鉴)

  1. 「声明在清单、代码惰性导入」是一条贯穿全栈的主线,不是局部优化。 从清单字段(modelCatalog / toolMetadata / contracts)、到 SDK 的 specifier 写法、到 runtime Proxy、到能力查询的三级回退,同一个原则被重复表达了四遍。依据:manifest-types.ts:338channel-entry-contract.ts:502loader-module-runtime.ts:224capability-provider-runtime.ts:502

  2. 契约声明是运行期强制,不是文档。 想注册工具就得先写 contracts.tools;想用 Codex 扩展工厂就得写 contracts.embeddedExtensionFactories。副产物是内核可以在不加载代码的前提下知道谁有什么(registry-registrars-tools-hooks.ts:231captured-registration.ts:231)。

  3. 模式标签与内部计划分离。 PluginRegistrationMode 是给插件看的六个词,PluginRegistrationPlan 是给内核用的四个布尔量。避免了「字符串比较散落在二十个地方」的经典腐化(plugin-registration.types.ts:398loader-registration-plan.ts:7registry-state.ts:29)。

  4. register 失败会回滚。 先拍注册表记录的快照,失败时还原记录并回滚全局副作用,再记错误。「插件炸了但网关还活着,且没有半截状态」(registry.ts:19-28loader-runtime-candidate.ts:552)。

  5. 重入用硬错误,辅助路径用软降级。 beginLoad 直接抛 PluginLoadReentryError,而 resolveRuntimePluginRegistry 遇到 in-flight 时返回 undefined——把「必须暴露的 bug」和「可以容忍的时序」分开对待(loader-cache-state.ts:48-53loader-runtime-registry.ts:22)。

  6. hook runner 实例稳定、钩子现查。 直接解决了「中途小范围激活把钩子丢了」这个隐蔽 bug:runner 只建一次,派发时从组合视图现查(hook-runner-global.ts:1-11:33)。

  7. 信任等级是一个整数排名,不是一堆 if。 四种 origin 加两个特例被压成 0–5 的排名函数,同 id 冲突的裁决只有一处(loader-provenance.ts:166-192)。

  8. 卸载先出计划再执行。 九项动作清单让「删之前先看清楚要删什么」变成结构化能力,而不是靠文案提醒(uninstall.ts:39-61)。


11. 边界与局限

  • 清单是「未签名的自述」。 内核信任 contracts / channels / providers 的声明来做定向加载;声明造假不会被清单本身发现,只会在注册期被守卫拦住。真正的信任裁决靠 origin 排名 + 安装期扫描 + ClawHub 完整性校验,不靠清单。
  • 插件元数据是进程级稳定的。 安装、清单、目录变化不会被运行时轮询感知——按 src/plugins/CLAUDE.md:32-33 写明的边界,需要重启网关或走显式的 reload / install / doctor 流程;热路径上刻意不做 stat / 重读 / 哈希。
  • registerContextEngine 是独占槽位(plugin-api.types.ts:313 的注释),memory 也是槽位模型(:439):同一时刻只有一个生效;bundled memory 模块被槽位策略挤掉时直接跳过(loader-runtime-candidate.ts:285-292)。
  • bundle 格式的能力仍在补齐。 加载器会对未接线的 bundle 能力发 warn("bundle capability detected but not wired into OpenClaw yet",loader-runtime-candidate.ts:586-593),MCP server 传输不接线的也会在诊断里点名(loader-runtime-candidate.ts:620-622)。
  • 仍有少量具名默认值。 如 memory dreaming 默认引擎 "memory-core"(src/memory-host-sdk/dreaming.ts:29)。这类例外是显式常量而非散落字面量,但确实存在。
  • 同一个插件被多处发现时,只有排名最高的会真的加载。 其余会以 disabled + overridden by <origin> plugin 出现在 openclaw plugins list 里——排查「我改了代码怎么没生效」时,这是第一个该看的地方。

12. 代码地图(导航索引)

主题文件路径符号名
清单类型src/plugins/manifest-types.tsPluginManifestPluginManifestActivationPluginManifestContracts
清单解析src/plugins/manifest.tsloadPluginManifestPLUGIN_MANIFEST_FILENAMEMAX_PLUGIN_MANIFEST_BYTES
清单登记表src/plugins/manifest-registry.tsloadPluginManifestRegistryCorePluginManifestRecordPluginManifestRegistry
发现src/plugins/discovery.tsdiscoverOpenClawPluginsPluginCandidateCandidateBlockReasonmergeDiscoveryResult
根目录src/plugins/roots.tsresolvePluginSourceRoots
origin / kindsrc/plugins/plugin-origin.types.tsplugin-kind.types.tsPluginOriginPluginKind
加载主流程src/plugins/loader.ts(门面)、loader-runtime-load.tsloadOpenClawPluginsPluginLoadOptionsloadPluginRegistryHandle
加载计划src/plugins/loader-registration-plan.tsPluginRegistrationPlanresolvePluginRegistrationPlan
加载缓存与重入src/plugins/loader-cache.tsloader-cache-state.tsPluginLoadReentryErrorclearPluginRegistryLoadCache
运行时注册表解析src/plugins/loader-runtime-registry.tsresolveRuntimePluginRegistry
冲突排名src/plugins/loader-provenance.tscompareDuplicateCandidateOrder
失败记录src/plugins/loader-records.tsloader-shared.tsrecordPluginErrorPluginLoadFailureError
候选加载与回滚src/plugins/loader-runtime-candidate.tsrollbackPluginGlobalSideEffects 调用点
注册表装配src/plugins/registry.ts + registry-registrars-*.ts + registry-state.tscreatePluginRegistryresolvePluginRegistrationCapabilities
注册表类型src/plugins/registry-types.tsPluginRegistryPluginRegistryParams
插件 API 类型src/plugins/plugin-api.types.tsplugin-definition.types.tsplugin-registration.types.tsOpenClawPluginApiOpenClawPluginDefinitionPluginRegistrationMode
钩子定义src/plugins/hook-types.tsPluginHookHandlerMap
钩子运行器src/plugins/hooks.tscreateHookRunner
全局钩子总线src/plugins/hook-runner-global.tshook-runner-global-state.tsinitializeGlobalHookRunnercreateLiveHookRegistryFacade
启动计划src/plugins/gateway-startup-plugin-plan.tsgateway-startup-plugin-config.tsresolveGatewayStartupPluginPlanFromRegistryshouldConsiderForGatewayStartuphasConfiguredActivationPath
工具解析src/plugins/tools.tsresolvePluginToolsensureStandalonePluginToolRegistryLoaded
provider 归属src/plugins/providers.tsresolveOwningPluginIdsForProviderresolveEnabledProviderPluginIds
能力 provider 解析src/plugins/capability-provider-runtime.tsresolvePluginCapabilityProviderresolvePluginCapabilityProviders
SDK 通道入口契约src/plugin-sdk/channel-entry-contract.tsdefineBundledChannelEntrydefineBundledChannelSetupEntryloadBundledEntryExportSyncresolveBundledEntryModulePath
外部包契约packages/plugin-package-contract/src/index.tsvalidateExternalCodePluginPackageJsonEXTERNAL_CODE_PLUGIN_REQUIRED_FIELD_PATHS
安装src/plugins/install.ts(门面)、install-package.tsinstall-npm.tsinstallPluginFromDirinstallPluginFromArchiveinstallPluginFromNpmSpec
安装安全扫描src/plugins/install-security-scan.runtime.tsscanPackageInstallSourceRuntimeInstallSecurityScanResult
ClawHubsrc/plugins/clawhub.tsinstallPluginFromClawHub
marketplacesrc/plugins/marketplace.tslistMarketplacePluginsinstallPluginFromMarketplace
更新src/plugins/update.ts(门面)、update-installed.tsupdate-source.tsupdateNpmInstalledPluginsPluginUpdateOutcomePluginUpdateIntegrityDriftParams
卸载src/plugins/uninstall.tsplanPluginUninstallUNINSTALL_ACTION_LABELSapplyPluginUninstallDirectoryRemoval
贯穿样例extensions/telegram/openclaw.plugin.jsonindex.tschannel-plugin-api.tssetup-entry.tspackage.json