OpenClaw 深度解析(四):插件 SDK 与扩展开发机制

OpenClaw 深度解析(四):插件 SDK 与扩展开发机制

场景:第三方贡献者想接入 Zalo

假设你是一位越南用户,想把 OpenClaw 接入 Zalo(越南最大的即时通讯平台)。

核心已经实现了 Telegram、Discord、Slack......但 Zalo 没有。你有两个路径:

  1. 向主仓库提 PR,等待审核合并,然后每次 Zalo API 变动你都要跟着 OpenClaw 的发版节奏走。
  2. 写一个独立的扩展包 ,在本地或发布到 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 总是解析到正在运行的那份核心代码,不会出现版本不匹配。

二、通道扩展协议:ChannelPluginChannelDock

接入一个新消息平台需要什么?

要把 Zalo 接入 OpenClaw,贡献者至少需要告诉核心:

  1. 怎么接收消息(入站)?
  2. 怎么发送消息(出站)?
  3. 这个平台有什么能力(能发图片吗?支持群聊吗?)?
  4. 消息来自谁才能被接受(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):

在把一个候选路径加入待加载队列之前,发现器会做三项检查:

  1. source_escapes_root:symlink 目标是否逃出了插件的根目录?(防止插件通过软链接读取系统文件)
  2. path_world_writable :目录是否有 o+w 权限位(mode & 0o002)?(防止任意用户写入插件代码)
  3. 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 接入。

相关推荐
冬奇Lab1 小时前
一天一个开源项目(第41篇):Workout.cool - 现代化开源健身教练平台,训练计划与进度追踪
docker·开源·资讯
IT_陈寒3 小时前
SpringBoot实战:5个让你的API性能翻倍的隐藏技巧
前端·人工智能·后端
机器之心3 小时前
让AI自我进化?斯坦福华人博士答辩视频火了,庞若鸣参与评审
人工智能·openai
iceiceiceice3 小时前
iOS PDF阅读器段评实现:如何从 PDFSelection 精准还原一个自然段
前端·人工智能·ios
AI攻城狮4 小时前
RAG Chunking 为什么这么难?5 大挑战 + 最佳实践指南
人工智能·云原生·aigc
yiyu07164 小时前
3分钟搞懂深度学习AI:梯度下降:迷雾中的下山路
人工智能·深度学习
掘金安东尼5 小时前
玩转龙虾🦞,openclaw 核心命令行收藏(持续更新)v2026.3.2
人工智能
南果梨5 小时前
OpenClaw 完整教程!从安装到使用(官方脚本版)
前端·git·开源
demo007x5 小时前
万字长文解读ClaudeCode/KiloCode 文件处理技术
人工智能·claude·trae