OpenClaw 深度解析(四):插件 SDK 与扩展开发机制
场景:第三方贡献者想接入 Zalo
假设你是一位越南用户,想把 OpenClaw 接入 Zalo(越南最大的即时通讯平台)。
核心已经实现了 Telegram、Discord、Slack......但 Zalo 没有。你有两个路径:
- 向主仓库提 PR,等待审核合并,然后每次 Zalo API 变动你都要跟着 OpenClaw 的发版节奏走。
- 写一个独立的扩展包 ,在本地或发布到 npm,任何人都能
openclaw install按需加载。
第二条路意味着 OpenClaw 核心必须提供一套稳定的扩展契约 ------不论贡献者用 TypeScript 还是 JavaScript,不论他们发布的是 .ts 源码还是编译后的 .js,都能被核心正确加载、隔离运行,并且即使扩展崩溃,核心也不会跟着挂掉。
这正是 Plugin SDK 需要解决的问题。
一、稳定契约:openclaw/plugin-sdk
为什么要有一个固定的 import 路径?
如果扩展直接 import { ... } from "../../src/plugins/types.js",任何一次内部重构都会破坏所有扩展。
OpenClaw 的解法是用 openclaw/plugin-sdk 作为公开 API 面,把内部实现细节隔离在这个路径后面:
typescript
// extensions/zalo/src/channel.ts
import type { ChannelPlugin, ChannelDock } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
typescript
// extensions/memory-core/index.ts
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
这个路径在运行时由 jiti (TypeScript 运行时加载器)alias 重定向到核心的 src/plugin-sdk/index.ts(开发环境)或 dist/plugin-sdk/index.js(生产环境):
typescript
// src/plugins/loader.ts(关键片段)
jitiLoader = createJiti(import.meta.url, {
interopDefault: true,
extensions: [".ts", ".tsx", ".js", ".mjs", ".cjs", ".json"],
alias: {
"openclaw/plugin-sdk": pluginSdkAlias, // 指向 src 或 dist
},
});
这个 alias 机制解决了三个问题:
- 扩展可以用
.ts源码发布,jiti 负责实时转译; - 扩展可以发布编译后的
.js,正常 require; openclaw/plugin-sdk总是解析到正在运行的那份核心代码,不会出现版本不匹配。
二、通道扩展协议:ChannelPlugin 与 ChannelDock
接入一个新消息平台需要什么?
要把 Zalo 接入 OpenClaw,贡献者至少需要告诉核心:
- 怎么接收消息(入站)?
- 怎么发送消息(出站)?
- 这个平台有什么能力(能发图片吗?支持群聊吗?)?
- 消息来自谁才能被接受(allowFrom)?
OpenClaw 把这些关注点用两个类型分开建模:
ChannelDock(能力声明):静态描述这个平台能做什么,以及如何解析路由决策。
typescript
// extensions/zalo/src/channel.ts
export const zaloDock: ChannelDock = {
id: "zalo",
capabilities: {
chatTypes: ["direct", "group"],
media: true,
blockStreaming: true, // Zalo 不支持流式输出
},
outbound: {
textChunkLimit: 2000, // 单条消息最大字符数
},
config: {
resolveAllowFrom: ..., // 从配置解析白名单
formatAllowFrom: ..., // 格式化展示白名单
},
groups: {
resolveRequireMention: () => true, // 群聊必须 @机器人
},
threading: {
resolveReplyToMode: () => "off", // Zalo 不支持线程回复
},
};
ChannelPlugin(生命周期):实现实际的连接/断开/消息收发逻辑,由可选的子接口(Adapter)组合而成:
typescript
interface ChannelPlugin {
dock: ChannelDock;
config?: ConfigAdapter; // 配置验证与实例化
security?: SecurityAdapter; // 签名验证、防重放
outbound?: OutboundAdapter; // 发送消息、上传媒体
pairing?: PairingAdapter; // 设备配对流程
groups?: GroupsAdapter; // 群组成员查询
gateway?: GatewayAdapter; // Gateway 启动时的钩子
agentTools?: AgentToolsAdapter; // 注入 Agent 专用工具
}
为什么要分成两个对象?
ChannelDock 里的东西是路由层在消息到达时需要的------"这条消息来自哪个平台?它是否应该被允许?用什么格式回复?"------这些判断必须快、纯、无副作用。
ChannelPlugin 里的逻辑涉及网络连接、有状态的 Adapter 实现,只在 Gateway 启动/关闭时才需要。两者生命周期不同,自然分离。
三、通用插件契约:OpenClawPluginDefinition
并非所有扩展都是消息通道。memory-core 提供记忆搜索工具,diagnostics-otel 提供 OpenTelemetry 遥测,这些都是通用扩展。
它们的契约是 OpenClawPluginDefinition:
typescript
type OpenClawPluginDefinition = {
id?: string;
name?: string;
description?: string;
version?: string;
kind?: PluginKind; // "memory" | "channel" | "provider" | ...
configSchema?: OpenClawPluginConfigSchema;
register?: (api: OpenClawPluginApi) => void | Promise<void>;
activate?: (api: OpenClawPluginApi) => void | Promise<void>;
};
两个核心字段:
configSchema:JSON Schema,定义该插件在openclaw.yml里可以接受的配置项。加载器会在register被调用之前,用 AJV 校验用户配置------配置不合法就直接拒绝加载,绝不让错误数据流入扩展。register(api):扩展的入口点。核心注入一个OpenClawPluginApi对象,扩展通过它声明自己能做什么。
四、能力注入:OpenClawPluginApi
OpenClawPluginApi 是核心注入给每个扩展的权限令牌。扩展只能通过这个对象告诉核心它想注册什么,而不能直接操作任何核心内部状态。
typescript
type OpenClawPluginApi = {
// 注册 LLM 工具(Agent 可以调用的函数)
registerTool(tool, opts?): void;
// 注册生命周期钩子
registerHook(events, handler, opts?): void;
on<K extends PluginHookName>(hookName, handler, opts?): void;
// 注册 HTTP 端点(供 Web UI 调用)
registerHttpHandler(handler): void;
registerHttpRoute(params): void;
// 注册消息通道
registerChannel(registration | ChannelPlugin): void;
// 注册 Gateway 控制平面方法
registerGatewayMethod(method, handler): void;
// 注册 CLI 子命令
registerCli(registrar, opts?): void;
// 注册后台服务(有 start/stop 生命周期)
registerService(service): void;
// 注册 LLM 提供商(Anthropic/OpenAI 等)
registerProvider(provider): void;
// 注册绕过 LLM 的斜杠命令(如 /tts)
registerCommand(command): void;
};
来看两个真实扩展如何使用这个 API:
memory-core(工具 + CLI 命令):
typescript
// extensions/memory-core/index.ts
register(api: OpenClawPluginApi) {
// 注册 LLM 工具:当 Agent 决定要搜索记忆时调用
api.registerTool(
(ctx) => {
const memorySearchTool = api.runtime.tools.createMemorySearchTool({
config: ctx.config,
agentSessionKey: ctx.sessionKey,
});
return [memorySearchTool, memoryGetTool];
},
{ names: ["memory_search", "memory_get"] },
);
// 注册 CLI 命令:用户可以 `openclaw memory ...`
api.registerCli(
({ program }) => { api.runtime.tools.registerMemoryCli(program); },
{ commands: ["memory"] },
);
},
diagnostics-otel(后台服务):
typescript
// extensions/diagnostics-otel/index.ts
register(api: OpenClawPluginApi) {
// 注册后台服务:Gateway 启动时 start(),关闭时 stop()
api.registerService(createDiagnosticsOtelService());
},
registerService 接受实现了 { start(): Promise<void>; stop(): Promise<void> } 的对象。核心负责在 Gateway 生命周期的正确时机调用它们,扩展不需要自己管理启动顺序。
五、生命周期钩子:24 个观察点
问题:扩展想拦截/修改中间状态
如果一个扩展想做"消息过滤"------在消息发出去之前把敏感词替换掉------它需要一个机会在"消息发送"的那一刻介入。
OpenClaw 为此设计了 24 个命名钩子,覆盖了从 Gateway 启动到消息处理再到 Agent 执行的完整生命线:
Gateway 层:
gateway_start → Gateway 启动完成
gateway_stop → Gateway 即将关闭
消息层:
message_received → 入站消息到达路由层(可拦截)
message_sending → 回复即将发送(可修改内容)
message_sent → 回复已发送
Agent 层:
before_model_resolve → 决定用哪个模型之前(可替换)
before_prompt_build → 构建 Prompt 之前(可注入系统提示)
before_agent_start → Agent 开始执行之前(可中止)
llm_input → 发送给 LLM 的完整输入(可观察)
llm_output → LLM 返回的完整输出(可观察)
agent_end → Agent 执行结束
工具层:
before_tool_call → 工具被调用之前(可拦截/替换)
after_tool_call → 工具调用返回之后(可修改结果)
tool_result_persist → 工具结果被持久化时
会话层:
session_start / session_end
before_message_write → 消息写入会话文件之前
before_compaction / after_compaction → 上下文压缩前后
before_reset → 会话重置前
子 Agent 层:
subagent_spawning → 即将派生子 Agent(可修改目标)
subagent_spawned → 子 Agent 已派生
subagent_delivery_target → 子 Agent 回复投递目标决策
subagent_ended → 子 Agent 结束
钩子注册时支持优先级:数字越大越先执行。
typescript
api.on("before_model_resolve", async (event) => {
// 根据当前对话内容动态选择模型
if (event.session.messageCount > 100) {
return { model: "claude-haiku-4-5" }; // 长对话切换到快速模型
}
}, { priority: 10 });
返回值的语义依钩子类型而定:
before_model_resolve返回{ model }→ 替换模型选择message_sending返回{ content }→ 替换发送内容before_tool_call返回{ skip: true }→ 阻止工具调用
六、插件发现:四级来源与安全校验
问题:哪些插件应该被加载?
安装在系统里的包很多,不是所有都是 OpenClaw 插件,也不是所有的 OpenClaw 插件都值得信任。
四级来源优先级(高优先级覆盖低优先级,同 id 只取最高级):
javascript
config (显式路径) ← 最高优先级,用户在 openclaw.yml 里指定的路径
workspace ← .openclaw/extensions/(项目级)
global ← ~/.openclaw/extensions/(用户级)
bundled ← 核心内置(最低,永远兜底)
这个设计让用户能在项目层面覆盖全局插件,在全局层面覆盖内置插件,同时保持核心兜底。
加载时的安全检查 (src/plugins/discovery.ts):
在把一个候选路径加入待加载队列之前,发现器会做三项检查:
source_escapes_root:symlink 目标是否逃出了插件的根目录?(防止插件通过软链接读取系统文件)path_world_writable:目录是否有o+w权限位(mode & 0o002)?(防止任意用户写入插件代码)path_suspicious_ownership:文件 uid 是否既不是当前用户也不是 root?(防止他人种植的插件被加载)
typescript
// src/plugins/discovery.ts(简化)
if (stat.mode & 0o002) {
candidate.diagnostics.push({ kind: "path_world_writable", ... });
}
if (stat.uid !== ownershipUid && stat.uid !== 0) {
candidate.diagnostics.push({ kind: "path_suspicious_ownership", ... });
}
此外,加载器在 require 插件入口文件之前还有一道边界文件检查 (openBoundaryFileSync):用 open(fd) 系统调用验证插件源文件确实在 realpath 解析后仍然属于插件根目录------这是防止 TOCTOU(检查-使用时间差攻击)的标准做法。
七、加载器:从发现到激活
加载器(src/plugins/loader.ts)是连接发现器和运行时的管道。核心流程如下:
scss
discoverOpenClawPlugins() → 扫描四个来源,得到候选列表
↓
loadPluginManifestRegistry() → 读取每个候选的 openclaw.plugin.json,
得到 id/kind/configSchema
↓
for (candidate of candidates):
1. resolveEffectiveEnableState() → 检查 plugins.allow / plugins.disable
2. validatePluginConfig() → AJV 校验用户配置
3. jiti(safeSource) → 加载模块(.ts 或 .js)
4. resolvePluginModuleExport() → 提取 register 函数
5. register(api) → 同步调用,注入 OpenClawPluginApi
6. registry.plugins.push(record) → 记录加载结果
两个关键设计决定:
注册必须是同步的 。加载器调用 register(api) 后,如果返回值是 Promise,会记录一条警告并忽略异步结果 。原因是 Gateway 启动时需要确切知道哪些工具/通道/钩子已经就绪,异步注册会引入不确定的就绪窗口。有持久化需求的初始化逻辑应该放进 registerService(它有明确的 start() 回调)。
插件崩溃不影响核心 。每个插件的 register 调用都被 try/catch 包裹,错误记录到 registry.diagnostics,其他插件继续加载。用户可以通过 openclaw plugins status 查看哪些插件加载失败以及失败原因。
八、插件清单:openclaw.plugin.json
扩展根目录下必须有一个 openclaw.plugin.json,声明插件的静态元数据:
json
{
"id": "memory-core",
"kind": "memory",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
configSchema 是必填字段------即使插件没有任何配置项,也要提供一个空的 schema(emptyPluginConfigSchema() 是辅助函数)。这个设计是刻意的:强迫插件作者明确声明"我不需要配置",而不是让核心猜测,避免配置项意外流入插件。
小结
Plugin SDK 的整个设计围绕一个核心问题展开:如何让第三方代码安全地扩展 OpenClaw,而不破坏核心的稳定性和安全边界?
| 机制 | 解决的问题 |
|---|---|
openclaw/plugin-sdk 固定路径 |
核心内部重构不破坏扩展 |
ChannelDock + ChannelPlugin 分离 |
路由判断(热路径)与连接生命周期解耦 |
OpenClawPluginApi 注入 |
扩展无法直接操作核心内部状态 |
| 24 个命名钩子 | 覆盖完整执行链路的观察与干预点 |
| 四级来源优先级 | 支持项目级/用户级插件覆盖内置行为 |
| 三项安全检查 + 边界文件验证 | 防止恶意/误植插件被加载 |
同步 register + registerService |
明确分离注册时机与初始化时机 |
openclaw.plugin.json 清单 |
加载前完成 id/kind/schema 的静态声明 |
下一篇,我们将进入 OpenClaw 的模型与提供商系统------探索核心如何在 Anthropic、OpenAI、本地 Ollama 等不同 LLM 后端之间完成路由,以及提供商扩展如何通过 Plugin SDK 接入。