OpenClaw 设计解析(八):安全模型

本篇聚焦 OpenClaw 的插件系统:插件包长什么样、如何被发现和加载、为什么 Hook 与 slot 是两个关键机制,以及这些设计背后的工程取舍。

1. 先看全貌:OpenClaw 的插件不是“动态 import 一下”

OpenClaw 的核心设计是 lean core:把通道、工具、记忆、后台服务、认证桥接这类可选能力尽量放到 extensions/ 里。
从当前仓库看,extensions/ 下已经有很多扩展目录,但它们并不是几套互相独立的小系统,而是都走同一条插件运行时。

flowchart TD P["插件来源<br/>bundled / workspace / global"] --> D["发现候选插件"] D --> M["读 manifest<br/>openclaw.plugin.json"] M --> L["判定是否启用<br/>校验 configSchema"] L --> J["Jiti 加载模块"] J --> R["plugin.register(api)"] R --> G["写入 PluginRegistry"] subgraph U["Registry 消费面"] direction LR A["Agent<br/>工具 / Hook"] C["Channels"] H["HTTP / Gateway"] S["Services / CLI / Commands"] end G --> A G --> C G --> H G --> S

最重要的心智模型不是“插件文件怎么 import”,而是:

插件的职责是把能力注册进系统;系统真正消费的是一个统一的 PluginRegistry

换句话说,插件系统解决的是“如何把外部能力接入核心运行时”,而不是“如何把代码拆成多个 npm 包”。

2. 一个插件在 OpenClaw 里到底是什么

从源码看,一个插件最常见有三部分:

  • package.json
    • 负责 npm 包元数据和 openclaw.extensions 入口声明
  • openclaw.plugin.json
    • 负责插件声明:idkindconfigSchema
  • index.ts
    • 负责真正注册能力

最小的心智模型可以压成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
export default {
id: "my-plugin",
kind: "memory", // 可选
configSchema: { /* JSON schema */ },

register(api) {
api.registerTool(...)
api.registerChannel(...)
api.on("before_prompt_build", async () => ({
prependContext: "...",
}))
},
}

更关键的一点是:插件不是“继承某个基类”,而是一个带 register(api) 的自注册对象。
你在 register() 里调用了什么 API,这个插件就拥有什么能力。

例如:

  • matrix 插件主要注册 channel
  • memory-core 插件注册 memory tools 和 CLI
  • diffs 插件同时注册 tool、HTTP handler、before_prompt_build hook

这也是 OpenClaw 很工程化的一点:插件的“种类”不是死板 class hierarchy,而是注册出来的能力组合。

2.1 为什么要单独有 openclaw.plugin.json

这里的设计非常关键。
OpenClaw 不是一上来就执行插件代码,而是先读 manifest:

  • 先知道插件 id
  • 先知道它是不是 kind: "memory"
  • 先拿到 configSchema
  • 先做配置校验和启用判断

这样做的好处很实际:

  1. 还没执行第三方代码,就能先做一轮筛选
  2. 配置不合法时,可以直接报错,不用 import 插件
  3. 像 memory 这种“单例槽位”可以在加载前就决定谁该启用

所以 manifest 的本质不是元数据装饰,而是:

把“声明阶段”和“执行阶段”拆开。

2.2 当前仓库里的插件版图

从当前仓库看,extensions/ 下有 40 个目录;其中 sharedtest-utils 是辅助包,真正带 openclaw.plugin.json 的运行时插件有 38 个。

如果按“它给 OpenClaw 增加了什么能力”来分,当前插件大致可以分成下面 5 类:

flowchart TD P["当前插件版图"] subgraph C["通道插件"] direction LR C1["telegram / slack / discord / ..."] end subgraph M["记忆插件"] direction LR M1["memory-core / memory-lancedb"] end subgraph A["模型 / Provider / Auth"] direction LR A1["copilot-proxy / gemini auth / portal auth"] end subgraph T["工具 / 工作流 / Hook"] direction LR T1["diffs / llm-task / lobster / open-prose"] end subgraph R["运行时服务 / 控制"] direction LR R1["voice-call / device-pair / phone-control / ..."] end P --> C P --> M P --> A P --> T P --> R

2.2.1 通道插件:把外部消息面接进 OpenClaw

这类插件最容易理解,本质上是在做“渠道接入”。
它们最核心的动作通常就是:

1
api.registerChannel({ plugin: xxxChannelPlugin })

当前仓库里的通道插件包括:

  • telegram
  • slack
  • discord
  • whatsapp
  • signal
  • matrix
  • googlechat
  • msteams
  • mattermost
  • feishu
  • line
  • irc
  • twitch
  • nostr
  • nextcloud-talk
  • synology-chat
  • imessage
  • bluebubbles
  • zalo
  • zalouser
  • tlon

这类插件解决的问题很明确:

  • 把某个平台的消息收进 OpenClaw
  • 把 OpenClaw 的回复再发回那个平台
  • 在需要时补 webhook / dock / 平台特有适配

例如:

  • googlechatzalobluebubblesnostr 不只是注册 channel,还会注册 webhook/HTTP handler
  • zalozalouser 还会补充对应的 dock 或额外工具

所以“通道插件”不是一个聊天 UI 皮肤,而是把整条消息收发链路接进来。

2.2.2 记忆插件:给 agent 一条长期记忆主路径

当前内置了两个 memory 插件:

  • memory-core
    • 默认文件型长期记忆实现
    • 提供 memory_searchmemory_get
    • 也会注册 memory CLI
  • 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
2
3
4
5
6
7
8
9
api.registerProvider({
id: "...",
auth: [
{
kind: "oauth" | "device_code" | "custom",
run: async () => ({ ... }),
},
],
})

它们的作用不是“替 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
可以把它理解成一张“能力总表”。

flowchart TD P1["插件 A"] --> R["PluginRegistry"] P2["插件 B"] --> R P3["插件 C"] --> R R --> T["tools"] R --> K["typedHooks / hooks"] R --> C["channels"] R --> H["httpHandlers / httpRoutes"] R --> G["gatewayHandlers"] R --> S["services"] R --> CLI["cliRegistrars / commands"] R --> PR["providers"]

它背后的意思非常简单:

  • Agent 运行时关心的是“当前有哪些工具、哪些 Hook”
  • Gateway 关心的是“当前有哪些 channel、哪些 HTTP 路由”
  • CLI 关心的是“当前有哪些附加命令”

这些消费者都不需要知道:

  • 插件是从 bundled 还是 global 来的
  • 插件是 TS 还是 JS
  • 插件入口文件叫什么

它们只需要查 PluginRegistry

这也是为什么文章里不应该把重点放在函数名本身,而应该放在这个动作上:

1
2
3
4
5
6
7
plugin.register(api)
-> api.registerTool(...)
-> registry.tools.push(...)

plugin.register(api)
-> api.registerChannel(...)
-> registry.channels.push(...)

从设计上看,这一步把“插件世界”翻译成了“核心运行时能理解的统一表结构”。

4. 插件发现:从哪里找,为什么按这个顺序找

OpenClaw 的发现顺序可以直接压成 4 层:

flowchart LR A["1. config 路径<br/>plugins.load.paths"] --> B["2. workspace<br/>.openclaw/extensions"] B --> C["3. bundled<br/>extensions/"] C --> D["4. global<br/>~/.openclaw/extensions"]

这个顺序不是随便排的,它体现了很明确的工程取舍:

  • config 路径最高
    • 方便开发调试,显式覆盖最强
  • workspace 次之
    • 项目级插件跟着代码仓库走
  • bundled 再后
    • 官方内置能力可以稳定存在
  • global 最后
    • 用户安装的全局插件不应该默默盖掉 bundled

特别是最后一点,源码里甚至专门写了注释:
全局自动发现要排在 bundled 后面,避免“用户随手装了一个插件就把内置能力影子覆盖了”。

4.1 发现阶段到底扫什么

发现时并不是只找 index.ts
逻辑大致是:

  1. 看传入的是文件还是目录
  2. 如果是目录,优先读 package.json 里的 openclaw.extensions
  3. 如果没声明,再回退到 index.ts / index.js / index.mjs / index.cjs
  4. 过滤掉 .d.ts
  5. 忽略 .bak.backup-*.disabled 这类目录

也就是说,发现阶段已经在做“把 npm 包形态翻译成插件候选项”这件事了。

4.2 发现阶段为什么就做安全检查

OpenClaw 没给插件做沙箱,插件本质上仍是受信任代码。
但即便如此,发现阶段还是做了几层很务实的 guardrail:

  • 禁止入口文件逃出插件根目录
  • 拒绝 world-writable 路径
  • 非 bundled 路径检查所有权是否可疑
  • 解析真实路径,防止符号链接把插件指到别处

所以这里的安全目标不是“把插件隔离起来”,而是:

尽量避免把明显不靠谱的路径,当成可执行插件候选。

5. 插件加载:为什么先判定、再 import、再注册

OpenClaw 的加载管线可以压成下面这张图:

flowchart LR subgraph S1["声明阶段"] direction TB A["读取 plugins 配置"] --> B["discover candidates"] B --> C["读取 manifest registry"] C --> D["决定是否启用<br/>allow / deny / entries / slots"] D --> E["校验插件配置"] end subgraph S2["执行阶段"] direction TB F["Jiti 懒加载模块"] --> G["取 default export"] G --> H["调用 register(api)"] H --> I["能力写入 PluginRegistry"] end E --> F

最关键的一点是:

不是所有“被发现”的插件都会被执行。

在真正 import 之前,加载器已经会先做:

  • 是否被禁用
  • 是否被 allowlist / denylist 命中
  • 是否被别的同 ID 插件覆盖
  • 是否满足 slot 条件
  • configSchema 是否存在
  • 用户配置是否通过 schema 校验

只有过了这些关,才会走到真正的模块加载。

5.1 一次加载的核心伪代码

如果把 loadOpenClawPlugins() 压成一段伪代码,核心大致是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
registry = createPluginRegistry()
candidates = discoverPlugins()
manifests = loadManifestRegistry(candidates)

for (candidate of candidates) {
manifest = manifests.get(candidate.rootDir)
if (!manifest) continue

if (alreadyOverriddenByHigherPriorityPlugin(manifest.id)) {
markDisabled("overridden")
continue
}

if (disabledByConfig(manifest.id)) {
markDisabled("config / allowlist / denylist")
continue
}

if (blockedBySlot(manifest)) {
markDisabled("slot decision")
continue
}

validatePluginConfig(manifest.configSchema, userConfig)

module = jitiLoad(candidate.source)
plugin = resolveDefaultExport(module)

plugin.register(api)
// api.registerXxx(...) 会把能力写进 registry
}

这比直接记函数名更重要,因为它说明了加载器的设计思路:

  1. 先做 cheap check
  2. 再做 expensive import
  3. 最后交给插件自注册

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
2
3
api.on("before_prompt_build", async () => ({
prependContext: "这里加一段提示",
}))

或者:

1
2
3
api.on("message_sending", async () => {
return { cancel: true }
})

这说明插件系统不只是扩展能力表,它还允许插件介入运行流程

6.1 两种 Hook 执行模型

Hook runner 的设计很清楚:按“会不会修改结果”分成两类。

flowchart LR subgraph N["通知型 Hook"] N1["message_sent"] N2["agent_end"] N3["gateway_stop"] end subgraph M["修改型 Hook"] M1["before_model_resolve"] M2["before_prompt_build"] M3["message_sending"] M4["before_tool_call"] end

更准确一点:

  • 通知型 Hook
    • 不关心返回值
    • 所有 handler 并行跑
    • 适合日志、观测、收尾通知
  • 修改型 Hook
    • 关心返回值
    • 按 priority 从高到低顺序执行
    • 适合改模型、改 prompt、拦截发送、改 tool 参数

6.2 为什么修改型 Hook 必须顺序执行

因为它们要解决的是“谁先改、怎么合并”的问题。

例如 before_prompt_build

  • 一个插件想 prepend 一段上下文
  • 另一个插件也想 prepend 一段上下文
  • 这时系统就必须定义:
    • 顺序
    • 合并规则
    • 冲突时谁优先

OpenClaw 的做法是:

  1. 按 priority 降序排
  2. 依次执行
  3. 用特定 merge 规则合并结果

所以它不是“广播通知”,而更像一个可组合的中间件链

sequenceDiagram participant P1 as Hook A(priority 10) participant P2 as Hook B(priority 5) participant P3 as Hook C(priority 0) participant R as Merge Result P1->>R: 返回结果 A P2->>R: 基于 A 合并结果 B P3->>R: 基于 AB 合并结果 C

6.3 一个很有代表性的例子

diffs 插件就是个很好的例子:

  • 它注册了一个工具
  • 注册了一个 HTTP handler
  • 还在 before_prompt_build 阶段加了一段 agent guidance

也就是说,它不是单纯“多了个 diff 工具”,而是在:

  • 能力层新增工具
  • 接口层新增访问入口
  • 提示层改写 agent 行为

这正是 OpenClaw 插件系统比较成熟的地方:插件可以同时扩多个面,而不需要改核心。

7. Slot:为什么 memory 插件只能有一个

插件系统里有一个很特殊的机制:slot

目前最典型的就是 memory 槽位。
原因很简单:长期记忆是一个“单例能力”,不能让两个 memory 插件同时当主路径。

flowchart TD A["memory-core"] --> S{"memory slot"} B["memory-lancedb"] --> S C["其他普通插件"] --> N["不受 slot 影响"] S -->|选中 memory-core| D["只启用 memory-core"] S -->|选中 memory-lancedb| E["只启用 memory-lancedb"] S -->|none| F["禁用所有 memory 插件"]

这背后的工程判断非常务实:

  • 如果 memory-corememory-lancedb 同时激活
  • 二者都可能注册 memory 工具、CLI、索引逻辑
  • 用户就会得到一套行为冲突的系统

所以 OpenClaw 干脆把它建模成:

某些能力不是“多插件并存”,而是“单槽位选择一个 owner”。

这也是为什么 manifest 里的 kind: "memory" 不是装饰字段,而会实实在在影响启用决策。

8. 安装:为什么安装器也围绕 manifest 转

插件安装路径也很符合前面的整体思路:先处理包,再按 manifest 认插件身份。

最常见的 npm 安装链路可以压成这样:

flowchart LR subgraph P["包处理"] direction TB A["npm pack --ignore-scripts"] --> B["下载 tarball"] B --> C["解压到临时目录"] end subgraph M["认插件身份"] direction TB D["读取 package.json<br/>+ openclaw.plugin.json"] E["确定 pluginId"] F["复制到 ~/.openclaw/extensions/<pluginId>"] G["下次启动时被 discover / load"] D --> E --> F --> G end C --> D

这里有两个很值得保留的工程细节:

  1. 优先用 openclaw.plugin.json 里的 id
    • 而不是盲信 npm 包名
    • 这样安装键和运行时插件 ID 保持一致
  2. 安装时有 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 个更底层的问题:

  1. 怎么在不膨胀核心的情况下持续加能力
    • lean core,能力外置
  2. 怎么在执行第三方代码前先做声明和校验
    • manifest-first
  3. 怎么把插件能力翻译成核心运行时可消费的统一结构
    • PluginRegistry
  4. 怎么让插件既能“加功能”,又能“改流程”
    • tool + hook 双扩展面
  5. 怎么避免单例能力互相打架
    • slot 机制

所以对读者来说,最值得记住的一句话是:

OpenClaw 的插件系统不是“动态 import 一堆扩展”,而是一套“声明 -> 发现 -> 启用决策 -> 自注册 -> 统一调度”的运行时接入框架。