
OpenClaw 设计解析(八):安全模型
OpenClaw 设计解析(八):安全模型
本篇聚焦 OpenClaw 的插件系统:插件包长什么样、如何被发现和加载、为什么 Hook 与 slot 是两个关键机制,以及这些设计背后的工程取舍。
1. 先看全貌:OpenClaw 的插件不是“动态 import 一下”
OpenClaw 的核心设计是 lean core:把通道、工具、记忆、后台服务、认证桥接这类可选能力尽量放到 extensions/ 里。
从当前仓库看,extensions/ 下已经有很多扩展目录,但它们并不是几套互相独立的小系统,而是都走同一条插件运行时。
最重要的心智模型不是“插件文件怎么 import”,而是:
插件的职责是把能力注册进系统;系统真正消费的是一个统一的
PluginRegistry。
换句话说,插件系统解决的是“如何把外部能力接入核心运行时”,而不是“如何把代码拆成多个 npm 包”。
2. 一个插件在 OpenClaw 里到底是什么
从源码看,一个插件最常见有三部分:
package.json- 负责 npm 包元数据和
openclaw.extensions入口声明
- 负责 npm 包元数据和
openclaw.plugin.json- 负责插件声明:
id、kind、configSchema等
- 负责插件声明:
index.ts- 负责真正注册能力
最小的心智模型可以压成这样:
1 | export default { |
更关键的一点是:插件不是“继承某个基类”,而是一个带 register(api) 的自注册对象。
你在 register() 里调用了什么 API,这个插件就拥有什么能力。
例如:
matrix插件主要注册 channelmemory-core插件注册 memory tools 和 CLIdiffs插件同时注册 tool、HTTP handler、before_prompt_buildhook
这也是 OpenClaw 很工程化的一点:插件的“种类”不是死板 class hierarchy,而是注册出来的能力组合。
2.1 为什么要单独有 openclaw.plugin.json
这里的设计非常关键。
OpenClaw 不是一上来就执行插件代码,而是先读 manifest:
- 先知道插件
id - 先知道它是不是
kind: "memory" - 先拿到
configSchema - 先做配置校验和启用判断
这样做的好处很实际:
- 还没执行第三方代码,就能先做一轮筛选
- 配置不合法时,可以直接报错,不用 import 插件
- 像 memory 这种“单例槽位”可以在加载前就决定谁该启用
所以 manifest 的本质不是元数据装饰,而是:
把“声明阶段”和“执行阶段”拆开。
2.2 当前仓库里的插件版图
从当前仓库看,extensions/ 下有 40 个目录;其中 shared、test-utils 是辅助包,真正带 openclaw.plugin.json 的运行时插件有 38 个。
如果按“它给 OpenClaw 增加了什么能力”来分,当前插件大致可以分成下面 5 类:
2.2.1 通道插件:把外部消息面接进 OpenClaw
这类插件最容易理解,本质上是在做“渠道接入”。
它们最核心的动作通常就是:
1 | api.registerChannel({ plugin: xxxChannelPlugin }) |
当前仓库里的通道插件包括:
telegramslackdiscordwhatsappsignalmatrixgooglechatmsteamsmattermostfeishulineirctwitchnostrnextcloud-talksynology-chatimessagebluebubbleszalozalousertlon
这类插件解决的问题很明确:
- 把某个平台的消息收进 OpenClaw
- 把 OpenClaw 的回复再发回那个平台
- 在需要时补 webhook / dock / 平台特有适配
例如:
googlechat、zalo、bluebubbles、nostr不只是注册 channel,还会注册 webhook/HTTP handlerzalo、zalouser还会补充对应的 dock 或额外工具
所以“通道插件”不是一个聊天 UI 皮肤,而是把整条消息收发链路接进来。
2.2.2 记忆插件:给 agent 一条长期记忆主路径
当前内置了两个 memory 插件:
memory-core- 默认文件型长期记忆实现
- 提供
memory_search、memory_get - 也会注册
memoryCLI
memory-lancedb- 向量记忆后端
- 除了 memory tools 和 CLI,还会在 agent 生命周期里做自动 recall / auto-capture
- 同时注册自己的后台 service
这两个插件之所以重要,是因为它们不是“多一个工具”这么简单,而是在定义:
OpenClaw 的长期记忆主路径到底由谁实现。
这也是为什么后面会有 memory slot 机制,强制只选一个 owner。
2.2.3 模型 / Provider / Auth 插件:给模型接入补登录和配置
这类插件主要解决“模型怎么接进来”和“用户怎么完成认证”。
当前比较典型的例子有:
copilot-proxy- 把本地 Copilot Proxy 暴露成 provider
- 让用户填 base URL 和模型列表,然后把 provider 配置补进 OpenClaw
google-gemini-cli-auth- 负责 Gemini CLI / Google Code Assist 的 OAuth
minimax-portal-auth- 负责 MiniMax 的 OAuth,并补 provider 配置和默认模型
qwen-portal-auth- 负责 Qwen portal 的 device-code OAuth,并补默认模型配置
这类插件最关键的动作通常是:
1 | api.registerProvider({ |
它们的作用不是“替 agent 多加一个 tool”,而是:
- 把新的 provider 暴露给配置系统
- 提供登录流程
- 在登录成功后把 credential、provider config、default model 一起写回系统
2.2.4 工具 / 工作流 / Hook 插件:直接增强 agent 能力
这类插件更像“给 agent 加脑子和手脚”。
当前比较典型的例子有:
diffs- 给 agent 提供只读 diff 查看和图像渲染
- 还会注册 HTTP handler,并在
before_prompt_build注入 guidance
llm-task- 一个 JSON-only 的通用 LLM 小工具,适合流程里的结构化子任务
lobster- typed workflow tool,支持 resumable approvals
thread-ownership- 通过 Hook 控制 Slack thread ownership,防止多个 agent 同时回复同一线程
open-prose- 更像一组随插件分发的 skill pack,本身运行时代码很薄
这类插件说明了一件很关键的事:
OpenClaw 的插件不只是“多加几个工具”,还可以直接介入 agent 的运行流程。
例如 thread-ownership 就不是新 tool,而是直接在 message_received / message_sending 这类 Hook 上改行为。
2.2.5 运行时服务 / 控制插件:给系统补后台能力
还有一些插件不偏消息通道,也不偏 agent 工具,而是在补 OpenClaw 自己的运行时能力。
比较典型的例子有:
voice-call- 语音通话运行时
- 会注册 gateway methods、tool、CLI、service
device-pair- 生成 setup code / 二维码并审批设备配对
phone-control- 管理高风险手机节点命令的 arm / disarm
- 带定时器 service 和命令入口
talk-voice/voice命令,用来列出和切换 ElevenLabs Talk voice
diagnostics-otel- 把诊断事件导出到 OpenTelemetry
acpx- 注册 ACP runtime backend service
这类插件说明 OpenClaw 的插件边界并不只在“agent 侧”,而是整个系统层面:
- 可以加命令
- 可以加 service
- 可以加 gateway method
- 可以加控制面能力
所以在 OpenClaw 里,“插件系统”更像一个统一扩展框架,而不是单纯的“agent tool marketplace”。
3. 真正的核心对象:PluginRegistry
加载完成后,OpenClaw 不会到处传“插件实例列表”,而是产出一个统一的 PluginRegistry。
可以把它理解成一张“能力总表”。
它背后的意思非常简单:
- Agent 运行时关心的是“当前有哪些工具、哪些 Hook”
- Gateway 关心的是“当前有哪些 channel、哪些 HTTP 路由”
- CLI 关心的是“当前有哪些附加命令”
这些消费者都不需要知道:
- 插件是从 bundled 还是 global 来的
- 插件是 TS 还是 JS
- 插件入口文件叫什么
它们只需要查 PluginRegistry。
这也是为什么文章里不应该把重点放在函数名本身,而应该放在这个动作上:
1 | plugin.register(api) |
从设计上看,这一步把“插件世界”翻译成了“核心运行时能理解的统一表结构”。
4. 插件发现:从哪里找,为什么按这个顺序找
OpenClaw 的发现顺序可以直接压成 4 层:
这个顺序不是随便排的,它体现了很明确的工程取舍:
- config 路径最高
- 方便开发调试,显式覆盖最强
- workspace 次之
- 项目级插件跟着代码仓库走
- bundled 再后
- 官方内置能力可以稳定存在
- global 最后
- 用户安装的全局插件不应该默默盖掉 bundled
特别是最后一点,源码里甚至专门写了注释:
全局自动发现要排在 bundled 后面,避免“用户随手装了一个插件就把内置能力影子覆盖了”。
4.1 发现阶段到底扫什么
发现时并不是只找 index.ts。
逻辑大致是:
- 看传入的是文件还是目录
- 如果是目录,优先读
package.json里的openclaw.extensions - 如果没声明,再回退到
index.ts/index.js/index.mjs/index.cjs - 过滤掉
.d.ts - 忽略
.bak、.backup-*、.disabled这类目录
也就是说,发现阶段已经在做“把 npm 包形态翻译成插件候选项”这件事了。
4.2 发现阶段为什么就做安全检查
OpenClaw 没给插件做沙箱,插件本质上仍是受信任代码。
但即便如此,发现阶段还是做了几层很务实的 guardrail:
- 禁止入口文件逃出插件根目录
- 拒绝 world-writable 路径
- 非 bundled 路径检查所有权是否可疑
- 解析真实路径,防止符号链接把插件指到别处
所以这里的安全目标不是“把插件隔离起来”,而是:
尽量避免把明显不靠谱的路径,当成可执行插件候选。
5. 插件加载:为什么先判定、再 import、再注册
OpenClaw 的加载管线可以压成下面这张图:
最关键的一点是:
不是所有“被发现”的插件都会被执行。
在真正 import 之前,加载器已经会先做:
- 是否被禁用
- 是否被 allowlist / denylist 命中
- 是否被别的同 ID 插件覆盖
- 是否满足 slot 条件
configSchema是否存在- 用户配置是否通过 schema 校验
只有过了这些关,才会走到真正的模块加载。
5.1 一次加载的核心伪代码
如果把 loadOpenClawPlugins() 压成一段伪代码,核心大致是:
1 | registry = createPluginRegistry() |
这比直接记函数名更重要,因为它说明了加载器的设计思路:
- 先做 cheap check
- 再做 expensive import
- 最后交给插件自注册
5.2 为什么用 Jiti
OpenClaw 这里没有强迫插件先预编译成 JS。
它用的是 Jiti,并且还是懒初始化:
- 没有插件要加载时,Jiti 实例甚至不会创建
- 需要时才开始支持
.ts/.tsx/.mts/.cts/.js/.mjs/.cjs - 还会给
openclaw/plugin-sdk打 alias,让插件开发者不用关心源码路径还是dist/路径
从工程角度看,这是一个非常实用的折中:
- 开发者体验好
- 插件可以直接写 TypeScript
- 核心运行时仍然掌控加载边界
- 不是把任意路径直接丢给 Node 原生 import
- 测试和启动更省
- 不需要时连加载器都不建
5.3 一个很容易忽略的限制:register() 必须同步
源码里有一个非常务实的约束:
register(api)可以返回 Promise- 但如果它真这么做,加载器只会给警告
- 不会等待异步注册完成
这背后的设计含义很明确:
插件注册阶段要尽量短、尽量同步,只做能力声明,不做慢初始化。
真正的异步动作更适合放到:
- service 生命周期
- hook 回调
- tool 执行
- HTTP handler / gateway handler
而不是放在 plugin bootstrap 自己身上。
6. Hook 系统:插件不只是在“加工具”,还在“改运行时”
如果只把插件理解成“加几个工具”,会低估 OpenClaw 的插件系统。
更强的一层是:插件可以在运行过程中插入 Hook。
开发者常见写法更像这样:
1 | api.on("before_prompt_build", async () => ({ |
或者:
1 | api.on("message_sending", async () => { |
这说明插件系统不只是扩展能力表,它还允许插件介入运行流程。
6.1 两种 Hook 执行模型
Hook runner 的设计很清楚:按“会不会修改结果”分成两类。
更准确一点:
- 通知型 Hook
- 不关心返回值
- 所有 handler 并行跑
- 适合日志、观测、收尾通知
- 修改型 Hook
- 关心返回值
- 按 priority 从高到低顺序执行
- 适合改模型、改 prompt、拦截发送、改 tool 参数
6.2 为什么修改型 Hook 必须顺序执行
因为它们要解决的是“谁先改、怎么合并”的问题。
例如 before_prompt_build:
- 一个插件想 prepend 一段上下文
- 另一个插件也想 prepend 一段上下文
- 这时系统就必须定义:
- 顺序
- 合并规则
- 冲突时谁优先
OpenClaw 的做法是:
- 按 priority 降序排
- 依次执行
- 用特定 merge 规则合并结果
所以它不是“广播通知”,而更像一个可组合的中间件链。
6.3 一个很有代表性的例子
diffs 插件就是个很好的例子:
- 它注册了一个工具
- 注册了一个 HTTP handler
- 还在
before_prompt_build阶段加了一段 agent guidance
也就是说,它不是单纯“多了个 diff 工具”,而是在:
- 能力层新增工具
- 接口层新增访问入口
- 提示层改写 agent 行为
这正是 OpenClaw 插件系统比较成熟的地方:插件可以同时扩多个面,而不需要改核心。
7. Slot:为什么 memory 插件只能有一个
插件系统里有一个很特殊的机制:slot。
目前最典型的就是 memory 槽位。
原因很简单:长期记忆是一个“单例能力”,不能让两个 memory 插件同时当主路径。
这背后的工程判断非常务实:
- 如果
memory-core和memory-lancedb同时激活 - 二者都可能注册 memory 工具、CLI、索引逻辑
- 用户就会得到一套行为冲突的系统
所以 OpenClaw 干脆把它建模成:
某些能力不是“多插件并存”,而是“单槽位选择一个 owner”。
这也是为什么 manifest 里的 kind: "memory" 不是装饰字段,而会实实在在影响启用决策。
8. 安装:为什么安装器也围绕 manifest 转
插件安装路径也很符合前面的整体思路:先处理包,再按 manifest 认插件身份。
最常见的 npm 安装链路可以压成这样:
这里有两个很值得保留的工程细节:
- 优先用
openclaw.plugin.json里的id- 而不是盲信 npm 包名
- 这样安装键和运行时插件 ID 保持一致
- 安装时有 targeted code scan
- 主要是 warn-only 的危险模式提示
- 不是沙箱,也不是硬阻断
所以安装器做的事情,本质上也和加载器一样:
- 识别插件身份
- 保证路径安全
- 把它放到一个标准发现位置
- 把最终执行推迟到下次正常加载流程
9. Plugin SDK:为什么插件开发者能像在写“内建模块”
对插件作者来说,最直接的体验是:
1 | import { ... } from "openclaw/plugin-sdk" |
他们不需要关心:
- 开发时是
src/plugin-sdk/* - 生产时是
dist/plugin-sdk/* - 插件被 Jiti 直接跑,还是被构建产物消费
因为加载器已经把这层 alias 处理掉了。
这意味着 Plugin SDK 的职责不是“多暴露点 helper”这么简单,而是:
给插件作者一层稳定的 API 边界,让第三方扩展像在写 OpenClaw 内建模块一样工作。
10. 小结:OpenClaw 插件系统真正解决了什么
如果只看表面,OpenClaw 的插件系统像是:
- 能发现插件
- 能加载 TypeScript
- 能注册工具和 Hook
但从源码设计看,它真正解决的是 5 个更底层的问题:
- 怎么在不膨胀核心的情况下持续加能力
- lean core,能力外置
- 怎么在执行第三方代码前先做声明和校验
- manifest-first
- 怎么把插件能力翻译成核心运行时可消费的统一结构
PluginRegistry
- 怎么让插件既能“加功能”,又能“改流程”
- tool + hook 双扩展面
- 怎么避免单例能力互相打架
- slot 机制
所以对读者来说,最值得记住的一句话是:
OpenClaw 的插件系统不是“动态 import 一堆扩展”,而是一套“声明 -> 发现 -> 启用决策 -> 自注册 -> 统一调度”的运行时接入框架。
- 感谢你的欣赏!



