拆开源项目 OfficeCLI 的插件协议:第三方格式处理器如何以独立进程接进主程序

2026-08-05

本文基于 OfficeCLI 仓库 commit 459b1a4(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/iOfficeAI/OfficeCLI 最新代码与文档为准。

这套插件协议里最值得抄的不是”怎么多支持一种文件格式”,而是它假定第三方代码一定会挂、会崩、会往错误的流里吐垃圾,然后把每一种挂法都提前映射成了一个有名字的错误码。 扩展点的难处从来不在于把外部代码调起来,而在于外部代码不听话的时候,你的主程序还能不能给用户一个可诊断的结论。这里说的 OfficeCLI 特指 GitHub 上 iOfficeAI/OfficeCLI 这个 Apache-2.0 开源项目,不是”用命令行操作 Office”这类泛称,它与微软之间也没有任何从属、授权或官方合作关系——只是用 .NET 生态里的开源 SDK 直接读写 .docx / .xlsx / .pptx 这三种公开文件格式,把这份能力做成单个二进制交给 AI Agent 调用。它自己的仓库里有一份写得相当细的插件协议文档,加上七个 C# 文件的实现。这篇就顺着这份实现读一遍。

先做个分工说明。站内已经写过几篇扩展机制的文章:MCP 的扩展框架讲的是模型与工具之间那层协议怎么扩展,Pascal Editor 的插件机制讲的是同一进程内的模块注册与装配,ECC 的插件与集成讲的是命令行 Agent 怎么把外部能力挂进自己的技能体系。本篇的位置不一样:它是一份”跨进程、跨语言、跨许可证”的扩展样本——扩展方是一个你无法信任、无法调试、甚至可能是闭源商业二进制的独立可执行文件。

一、它要解决的耦合,不只是”格式不够”

翻开 plugins/plugin-protocol.md 第一节,动机写得很直白:主仓只做 .docx / .xlsx / .pptx 三种通用格式,其它格式通过插件交付。列出的四条具体理由,每一条对应一种不同的耦合:

老格式(.doc.rtf.odt)解析器重、格式在退场,塞进主二进制不划算;地区格式(.hwpx.hwp)由主团队之外的社区维护,维护权不在自己手里;导出目标(.pdf.epub)的渲染库有体积、许可证或平台约束;还有一类是需要留在 Apache 许可证主仓之外的闭源实现。

体积、维护权、许可证、商业模式——四种耦合里只有第一种是纯技术问题。这决定了它不可能选”动态加载 DLL”这条路:进程内加载解决不了许可证传染,也解决不了”第三方代码把主进程带崩”。所以协议一开始就锁死在独立进程 + 标准流上。

在这个前提下,协议 v1 定义了三种插件形态(manifest 里叫 kind),职责、生命周期、通信模式都不一样:

dump-reader 是一次性的。它读一个外部格式的源文件,往 stdout 吐 JSONL——JSONL 就是”每行一个完整 JSON 对象”的流式格式,跟一次性吐一个大数组相对。吐出来的每一行是一条 officecli 自己的命令(add / set),主程序建一个空白的原生骨架文件,把这些命令逐条回放进去。词汇表用的是主程序的,插件不许自创。

exporter 更简单,纯 CLI 调用,没有任何消息往来:主程序给它源文件路径和目标路径,它自己读、自己写、自己退出。协议里对它有一条硬要求——不许写源文件。原文写的是主程序据此跳过防御性快照,也就是说源文件是原样递过去的,插件乱写就是真的把你的文件改了。

format-handler 最重,插件全权拥有一种外部格式:长驻整个会话,握着文件读写句柄,主程序把每一次文档操作都变成一次 IPC。IPC 就是进程间通信,这里的具体形态是主程序往插件的标准输入写一行 JSON、再从它的标准输出读一行 JSON 回来,一问一答。它的词汇表是插件自己定义的,在 manifest 里声明、在会话开始时再快照一次。

还有两个保留字 enginetransformer,v1 明确禁止插件声明。

二、一份清单换来的全部信任

主程序对插件的全部先验认知,来自一次 <plugin> --info 调用返回的单个 JSON 对象。这个对象的 C# 模型在 PluginManifest.cs,值得逐字段看,因为每个字段背后都是一次”信任多少”的决策。

必填字段里,protocol 是唯一的硬门。PluginRegistry 里有个常量 SupportedProtocolVersion = 1TryReadManifest 解析完 JSON 立刻比对,不等于就拒绝加载,并且往 stderr 打一行 warning。代码注释解释得很清楚:静默拒绝会让用户在排查”插件怎么找不到”的时候完全没有线索。这是个很小但很值钱的细节——扩展点的失败必须是可见的失败。

idle_timeout_seconds 是个对象而不是一个数:一个 default 加一份按动词(verb)拆分的 verbs 覆盖表。协议规定 default 必填且必须是正整数,0 在清单里不允许,理由是避免”静默的永不杀死”。但代码比协议宽容一档:PluginIdleTimeout.SafeDefault 是 60 秒,清单里没写这一块时兜底用它,同时 Warnings() 会把这件事记成一条软告警。

这个”硬门 + 软告警”的分层是这份实现里反复出现的模式。Warnings() 方法一共产出四类告警:缺 idle_timeout_seconds.defaultkinds 为空或含未知值、dump-reader 声明了 docx/xlsx/pptx 之外的 targetformat-handlervocabulary。这些都不阻止插件加载,但 plugins listplugins lint 都会把它们打出来。注释里写了动机:让插件作者在发现阶段就看到偏移,而不是等用户在第一条命令上撞见。

target 字段还有个向后兼容的小动作。ResolveTargetFormat 在字段缺失时默认返回 "docx",让早于这个字段的插件仍然可加载;但字段写了却不是三个合法值之一,就直接抛异常。缺失 = 宽容,写错 = 拒绝,这个区分是对的。

runtime 字段(dotnet / native / go / rust / python / other)值得单说:协议和代码注释都强调主程序不会根据它分支,纯粹用于诊断和 plugins list 显示。声明一个不影响行为的字段,是为了让 plugins list 的输出对人有意义,而不是给宿主留一个悄悄改行为的口子。

三、四条发现路径,与那条安全硬化

PluginRegistry.CandidatePaths 按固定优先级产出候选可执行文件路径,第一个匹配的胜出:

  1. 环境变量 OFFICECLI_PLUGIN_<KIND>_<EXT>,值是绝对路径。名字由 kind 的 wire 形式大写、连字符换下划线拼出来,例如 OFFICECLI_PLUGIN_DUMP_READER_DOC
  2. 用户目录 ~/.officecli/plugins/<kind>/<ext>/plugin(Windows 上先找 plugin.exe)。
  3. 主程序旁边的 plugins/<kind>/<ext>/plugin
  4. PATH 查找,名字是 officecli-<kind>-<ext> 或退一步的 officecli-<ext>

第四条上挂着一段安全硬化,是全文我最想让你去读原文的一段。PathCandidates 在遍历 PATH 目录时会跳过两类目录:非绝对路径的条目(.、空串、src/bin 这种),以及 world-writable 目录(Unix 的 other-write 位,Windows 用 ACL 所以恒返回 false)。注释直接点名这是经典的执行劫持向量——往这类目录里丢一个 officecli-doc,下一次打开文档就会执行它。

这条硬化的形状很有代表性:它没有试图做签名校验,只是把”任何人都能写入的目录”从可执行文件的解析范围里剔掉,成本近乎为零而挡掉了最廉价的那种攻击。同类的取舍在 MCP 的安全边界里也能看到,都是在”扩展点等于执行入口”这个前提下先把最便宜的口子堵上。

解析结果按 (kind, ext) 缓存在进程生命周期内,负结果同样缓存——没找到就不会在每次操作时重新探测一遍。这一点在 Agent 高频调用场景下不是小优化:TryReadManifest 里的探测超时常量 InfoTimeoutMs = 5000,也就是说一次失败的探测最坏要吃掉 5 秒。

TryReadManifest 本身还埋了个 .NET 子进程的经典坑,注释写得很老实:必须在 WaitForExit 之前就把 stdout 和 stderr 的异步读起来。同步”先等退出再读”在输出超过管道缓冲区时会死锁——清单本身不大,但插件在 --info 时顺手往 stderr 打一堆诊断就会撞上。plugins info 命令里同样的地方也修过一次,注释里明说之前 Kill 兜底代码坐在死锁后面根本够不着。

四、看门狗盯的是空闲,不是总时长

PluginProcess.cs 是短生命周期插件的统一驱动,同时也是 format-handler 的 spawn 侧。它实现的看门狗只有一条规则:任何 stdout 字节,或 stderr 上一行 {"heartbeat":true},都重置活跃计时器;超过预算就杀掉整棵进程树。墙钟总时长不设上限。

协议 FAQ 里给了理由:大 .doc 文件 dump 几分钟是正常的,某些导出任务甚至要跑很久,墙钟上限惩罚的是正确行为,空闲超时才抓得住真正的假死。代码注释里举的例子是”一个 4 GB 的 .doc 跑 20 分钟但一直在产出,这没问题”。

实现里有两个细节值得停一下。

一是时间源的选择。代码用的是 DateTime.UtcNow.Ticks 这个墙钟值,而不是通常被认为更”正确”的单调时钟 Stopwatch。这两个词值得先分清:墙钟就是你抬头看墙上挂钟读到的那个绝对时刻,会被系统对时和时区调整拨动;单调时钟只保证”永远向前、步长均匀”,用来测两点之间的间隔更可靠,所以教科书讲计时一般都推荐它。注释给的理由是系统挂起:单调时钟在不同平台/硬件组合下,挂起期间有的继续走有的暂停;而墙钟在挂起期间一定推进。笔记本合盖一小时再打开,墙钟给出的是诚实的”空闲了一小时”,于是杀掉那个大概率已经僵死在废弃句柄上的插件。这是一个把”教科书正确”让位给”故障语义正确”的判断。

二是心跳的识别。IsHeartbeat 先做廉价预筛——长度小于 14 直接否、首字符不是 { 直接否、不含 heartbeat 子串直接否——过了才走 JSON 解析。因为这个函数要对每一行 stderr 诊断都跑一遍。识别出来的心跳行被看门狗吃掉,不会冒到用户面前;非心跳的 stderr 行同样算活跃,但会被收集起来。收集有上限:16 KB,超了就截断,且保留头部,注释的理由是第一行错误通常最有用。

FormatHandlerSession 在长会话里复用了同一套语义,但读取方式不同:它不能简单地 ReadLineAsync 加一个固定取消期限,那样会杀掉一个正在心跳、只是回复慢的插件。它的做法是把 ReadLine 丢进一个 Task,然后按 max(250ms, 预算/4) 的间隔轮询,每次醒来重新比对活跃时间戳。轮询下限 250 毫秒,这样短预算也能大致按时触发。

五、把外部进程装成一个本地对象

FormatHandlerProxy 实现了主程序内部的 IDocumentHandler 接口,把每一次调用翻成一条 IPC 消息。这层的价值在于主程序里那些既有的 get / view / query 管线不需要知道文件后面是不是个插件。

代理层的严格程度是分层的,这个分层比”全严”或”全松”都更有意思:

回复的结构形状,它非常严。set 的回复必须是一个带 unsupported_properties 数组的 JSON 对象,插件如果偷懒直接回一个裸数组,代理会抛 protocol_mismatch 而不是宽容吸收。注释写了原因:宽容吸收会让插件作者永远发现不了自己的偏移。add 的回复同理,必须是带 path 字段的对象,裸字符串直接失败。

数值的编码,它很松。byte_count 这类整数字段有个 ParseLongTolerant,long 不行试 double,double 不行试字符串。协议里明写了这条容忍度,理由是跨语言运行时对整数的 JSON 编码会漂移——Go、Python、JavaScript 里一个整数序列化成 42.0 太常见了。

能力缺失,它走短路。开握手时插件回一份 capabilities.commands 列表,会话把它缓存下来;后续命令不在列表里,直接以 unsupported_command 失败,连往返都省了。而代理里那几个可选能力(ViewAsSvgViewAsHtmlViewAsFormsJsonTryExtractBinary)专门 catch 这个错误码并返回 null,让上层去走自己的降级。

握手本身还有个设计取向值得记:manifest 里的 vocabulary 和握手回复里的 vocabulary 快照是两份,允许不一致,主程序信后者。协议对此的定性是”词汇表是文档,不是运行时闸门”——主程序不会因为一条命令用了词汇表外的属性名就拒绝它,而是把命令发下去,由插件在 set 回复的 unsupported_properties 里自报。文档管发现和帮助输出,真相在处理器那一端。

会话状态机只有五个状态:spawning → ready → busy → broken → closed。关键规则是 broken 不可逆:任何 IO 失败、看门狗击杀、格式错误的回复,都把会话标成 broken,之后的 Send 立刻以 plugin_stream_closed 快速失败,不自动重启,要重来由调用方自己 Dispose 再开。Dispose 时先发 close 再关 stdin 给出 EOF,等 2 秒不退就杀进程树。

组成部分它负责什么仓库位置你什么时候会碰到它
协议文档三种 kind、发现顺序、清单字段、消息信封、错误码与退出码的唯一真相plugins/plugin-protocol.md动手写插件之前
清单模型PluginManifest / PluginIdleTimeout / PluginVocabulary,以及 Warnings() 软告警src/officecli/Core/Plugins/PluginManifest.cs--info 输出、排查”字段写了没生效”
发现与探测四条搜索路径、PATH 硬化、清单缓存、协议版本硬门src/officecli/Core/Plugins/PluginRegistry.cs插件装了但主程序找不到
子进程驱动短生命周期调用 + 空闲看门狗 + 心跳识别 + stderr 收集src/officecli/Core/Plugins/PluginProcess.cs插件疑似假死、诊断信息被截断
长会话通道format-handler 的 spawn、开握手、请求串行化、broken 状态src/officecli/Core/Plugins/FormatHandlerSession.cs长驻插件中途断流
代理层把插件包装成主程序内部的文档处理器接口src/officecli/Core/Plugins/FormatHandlerProxy.cs回复形状不合规、可选能力降级
一次性调用dump-reader 的 JSONL 回放、exporter 的调用与退出码映射src/officecli/Core/Plugins/DumpReaderInvoker.csExporterInvoker.cs转换结果为空、导出没产文件
命令面plugins list / plugins info / plugins lintsrc/officecli/CommandBuilder.Plugins.cs自检插件、CI 里卡门
接入点打开非原生后缀时落到插件分支src/officecli/Handlers/DocumentHandlerFactory.cs想知道插件是从哪一步被调起来的

六、边界与代价:它明确不管的那些事

**放弃了进程内性能。**每一次 format-handler 的文档操作都是一次跨进程往返,请求还必须串行——会话内有一把 IO 锁,一个请求收到回复之前不发下一个。这是换隔离性付的账,协议也没打算掩饰。

**放弃了并发文档。**协议第 13 节把”format-handler 是否支持一个进程内多文档会话”列为待议问题,v1 的答案是不支持,一个打开的文档一个进程。

**不做防御性快照。**exporter 拿到的是源文件的真实路径,协议把”不许写源文件”定为插件侧的硬约束,主程序不设防。插件违约的后果直接落在你的文件上。

**不做词汇表校验。**前面说过,主程序不拦超出声明词汇表的命令。好处是插件永远是自己能力的唯一真相,坏处是拼错一个属性名不会在主程序侧被拦下,只会体现在回复的 unsupported_properties 里——你得自己去看这个数组。

lint 只覆盖一种 kind。plugins lint 的实现里明确拒绝非 dump-reader 插件,报 unsupported_plugin_kind。它做的事是跑一遍 plugin dump <fixture>,把每条 add / set 的属性键拿去比对目标格式的 schema 树,有未声明的键就退出码 1。exporter 的产物是二进制、format-handler 的契约是词汇表块,都不走这个校验器。这里的 fixture 指的是校验时喂给插件的那个固定样本文件,跑一遍就能拿到可预期的输出,用来当基准。而且它不带默认值,必须靠 --fixture 参数或 OFFICECLI_LINT_FIXTURE 环境变量指定——注释里写的理由是 fixture 属于每个插件自己的事,由插件作者或 CI 来钉死,宿主不提供任何捆绑兜底;缺了会报 lint_fixture_missing,提示是”给一个插件能 dump 的小体积外部格式文件”。

**安装机制完全不管。**协议第 8 节明说不强制任何安装方式,只要可执行文件落到四条发现路径之一就行。文档里提到了一个内置安装器和一个带 SHA-256 的注册表地址,但我在这个快照的命令注册代码里只看到 list / info / lint 三个子命令——PluginRegistry.InvalidateCache 的注释里也把安装命令写成了将来的用途。想批量分发插件,眼下要靠自己的手段。

**文档与代码存在几处漂移,得以代码为准。**协议第 7.2 节提到词汇表以 schemas/word-vocabulary.json 之类的文件发布,但这个快照里没有这样的文件,实际的 schema 树在 schemas/help/ 下按 docx / xlsx / pptx 三个目录分开(ls schemas/help/docx | wc -l 数出来是 46 个条目),lint 的报错信息里指的也是 schemas/help/<target>/<element>.json 这个路径。这类漂移不是 bug,是文档跑在实现前面的正常状态,但你照着文档写工具会扑空。

七、上手与避坑清单

别信 --timeout 0协议 5.6 节和好几处错误提示里都写着”用 --timeout 0 关掉看门狗”,但在这个快照的源码里搜不到这个命令行选项的定义;协议 5.4 节自己也说了宿主不会把 CLI 标志转发进插件进程做超时用途。真正起作用的是环境变量 OFFICECLI_PLUGIN_IDLE_TIMEOUT_SECONDS:设 0 关掉看门狗,设 N 则覆盖所有动词的预算。ResolveIdleTimeout 里的注释解释了为什么它连每动词的清单配置一起盖掉——用户既然已经在排查一个行为异常的插件,这时候还尊重插件自报的限额就没意义了。会踩是因为文档和实现在这里对不齐,避的办法是用环境变量、并且知道它是全局覆盖不是叠加。

**stdout 只走协议帧,一个字节的调试输出都不行。**这是三种 kind 通用的铁律。format-handler 往 stdout 写了非信封内容,主程序报 protocol_mismatch 并把会话打成 broken;dump-reader 吐了顶层 JSON 数组,直接 corrupt_batch。会踩是因为几乎所有语言的默认 print 都往 stdout 去,调试时随手加一行就炸。避的办法是插件启动第一件事就把日志器绑到 stderr 或 --log-file,别指望宿主帮你过滤——注释里写得很清楚,主程序不防御被污染的 stdout。

**换行符和 BOM 在 Windows 上会咬人。**协议要求所有平台都用 \n、UTF-8 不带 BOM。宿主侧确实做了兜底:FormatHandlerSession 强制三个流都用不带 BOM 的 UTF-8(注释点名 Windows 的控制台编码可能是 GBK 或 CP1252,而 wire 格式必须与本地化无关),DumpReaderInvoker 也会剥掉每行开头的 BOM 字符再 trim。但这是兜底不是许可,别把行为建立在宿主愿意擦屁股上。

dump-reader 会在你的源文件旁边留下一个新文件。这条最容易被忽视,也是唯一一条会改变你磁盘目录内容的。打开一个 .doc 时,主程序先看同目录下有没有同名的 <源文件名>.<target>(target 由清单决定,通常是 .docx),有且比源文件新就直接用它、连插件都不调;没有就调插件转换,然后把结果写进这个兄弟文件,并往 stderr 打一行 note 告诉你生成了它。此后你的所有编辑落在兄弟文件上,原始的 .doc 不会被改。会踩的场景是:你以为自己在编辑 .doc,把它发给别人,对方打开看到的还是旧内容。避的办法是记住”转换是一次性的、编辑目标是兄弟文件”,要强制重转就删掉或改名兄弟文件——靠的是修改时间比对。

**空输出配上退出码 0 是个陷阱,好在有告警。**插件正常退出但一条命令都没吐,生成的原生文件就是空的。这种情况主程序会打一行 warning,明说”这通常是插件的能力缺口,不是源文件的属性”。会踩是因为退出码 0 天然被当成成功;避的办法是把这行 stderr 告警当错误处理,尤其在批处理脚本里。

**中途失败的清理边界要看清。**回放过程中任何一条命令炸了,DumpReaderInvoker 会抛出带 plugin_command_failed 的异常,并删掉那个临时文件——半成品不会留在临时目录,兄弟文件也不会被写出去。但如果插件自己在转换过程中往源目录写了东西(协议里提到有些插件的转换路径天然会这么做,宿主还专门为此加了一段”如果兄弟文件已经新鲜就优先用它”的逻辑),那部分不在宿主的清理范围内。

**别在 stdout 回调里做重活。**这条是从一段带版本注记的注释里读出来的教训。早先的实现是在 PluginProcess 的 stdout 读取任务里直接回放每一条批命令,也就是在后台线程上操作 OOXML 文档——OOXML 就是 .docx 这类文件的本质,一个装着若干 XML 分部的 zip 包,而操作它的 SDK 内部包状态不是线程安全的。大批量命令触发多个分部以更新模式反复开关时,会在保存那一刻间歇性地抛”条目不能在更新模式下多次打开”。现在的做法是先把所有 JSONL 行缓冲下来,等插件进程退出后再在调用线程上同步回放。避的办法很直白:跨进程流的回调线程上只做解析和缓冲,别碰有状态的资源。

**发现顺序意味着环境变量能劫持一切。**第一条路径就是环境变量指向的绝对路径,优先级高于用户目录和捆绑目录。这在调试时很好用,在 CI 或共享机器上就是个需要管的东西。这类”扩展点即执行入口”的账,和 Agent 最小权限设计里讨论的是同一笔。

**常驻模式下”改完了”不等于”落盘了”。**这一条不属于插件协议本身,但会和插件路径叠加。仓库 README 说明了常驻模式把文档留在内存里,主程序自己的读命令总能看到最新编辑,但磁盘上的文件是延迟写的:空闲后会按文档保存成本自适应地在 2 到 10 秒内自动刷新,也可以用 OFFICECLI_RESIDENT_FLUSH=each 让每次改动在命令返回前落盘,或显式 save / close。任何非它自己的程序要读这个文件之前,先确认已经刷过。

结尾:一份自检清单

如果你打算照着这套思路给自己的 CLI 设计扩展面,可以拿这几个问题对一遍:

  • 宿主对扩展的全部先验认知,是不是收敛在一个可以一次性获取、可以离线检查的清单里?
  • 清单里的每个字段,是硬门(不合规就拒绝加载)还是软告警(记下来但放行)?这个分层你想清楚了吗?
  • 扩展挂死的时候,你的判据是”跑太久”还是”太久没动静”?前者会误杀正确行为。
  • 扩展违反协议的时候,是宽容吸收还是响亮失败?形状类的错误建议响亮,编码类的差异建议宽容。
  • 扩展的可执行文件从哪些路径解析?其中有没有任何人都能写入的目录?
  • 出错之后,磁盘上会剩下什么?这个答案你能不能一句话说清?

想继续读代码的话,建议的顺序是:先通读 plugins/plugin-protocol.md 建立全局,再看 PluginRegistry.cs 弄清插件是怎么被找到的,然后 PluginProcess.cs 看看门狗,最后 FormatHandlerSession.csFormatHandlerProxy.cs 一起看——这两个文件才是”跨进程扩展”这件事真正的重量所在。

本篇属于一个把开源Office 文档读写套件 OfficeCLI逐层拆开讲的系列,整体地图见 OfficeCLI 是什么:给 AI Agent 用的开源文档读写套件;沿着这条线往下,还可以看 开源项目 OfficeCLI 的 Mermaid 图形编译链路拆解开源项目 OfficeCLI 的安全边界:四道防线挡住了什么、漏了什么

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。