ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

深入 Multica 插件 Hook 引擎:用 triage-notify 示例掌握双向调用的出站签名与回调安全

2026/9/8 19:14:18 拓冰建站 浏览量
深入 Multica 插件 Hook 引擎:用 triage-notify 示例掌握双向调用的出站签名与回调安全 深入 Multica 插件 Hook 引擎用 triage-notify 示例掌握双向调用的出站签名与回调安全【免费下载链接】multicaMake humans and AI agents work as one team — open-source and self-hostable.项目地址: https://gitcode.com/GitHub_Trending/mu/multicatriage-notify是 Multica 开源仓库中演示Hook 引擎出站方向的官方示例插件Multica 作为主机把签名后的 POST 请求发给插件作者自己运营的服务器该服务器再凭一次性callback_token回调 Multica 写入评论。相比前三个只做「沙箱面板向主机要东西」的入站示例本示例第一次引入了真正的后端因而触发了 Hook 引擎安全模型中的每一项检查。本文以 examples/plugins/triage-notify/README.md 为主线骨架结合仓库中的插件清单、参考处理器实现、面板端代码以及服务端 plugin_hook.go 中的真实实现完整讲解四种触发方式、处理器端四条硬性安全要求、一次性回调令牌的使用边界以及net:出站域名的授权模型。读完本文你将掌握一个带后端的 Multica 插件从清单声明到本地联调的全流程作为插件作者如何在自己的服务器上实现防重放、防伪造的签名校验以及哪些安全责任主机替你做、哪一件主机永远无法替你完成。示例全景三个文件一段完整的出站调用链整个示例只有三个交付物恰好对应插件模型中的三条线文件角色说明multica.plugin.json清单声明 scope、配置表单、issue_panel 面板与两个 hookui/main.js入站 surface沙箱 iframe 内由插件自绘的「Triage this issue」按钮server/handler.mjs出站 handler插件作者自行运营的 HTTP 服务器接收主机签名 POST 并回调主机清单中contributes.surfaces声明了一个issue_panel类型的triage面板支持 web/desktop 两个平台contributes.hooks声明了两个 hook业务用的triage_issueSends the issue title and description to the triage service and posts its suggested owner and priority back as a comment和仅用于演示调度投递身份的scheduled_heartbeat。面板与 handler 之间的调用并不直连。ui/main.js开头的注释说明了这样设计的原因面板不去直接 fetchtriage.example.com即便那是插件自己的服务器而是经由主机转发调用才具备签名、限流、被net:域约束、为管理员留痕、以及获得短生命周期回调令牌这些特性直连自己的后端则一样都没有用户也查不到任何记录。从源码结构看这条转发链路分为两段面板通过私有 MessagePort 桥发起请求主机在自己的会话中执行真正的调用。桥接协议在 packages/plugin-sdk/protocol.ts 中有清晰描述——identity 由 MessagePort 绑定而非event.origin且没有任何凭据跨过该边界面板只提问主机代办并返回结果。SDK 侧封装见 packages/plugin-sdk/index.ts 的multica.hooks.invoke其注释明确写着主机只接受清单里用uitrigger 声明过的 hook 调用。四种触发方式谁发起的调用决定了评论署名triage hook 可被三种途径触达心跳则增加第四种定时途径。README 给出的对照表是整个示例的核心Trigger来自哪里是否阻塞评论作者ui面板上的Triage this issue按钮仅阻塞面板点击的人manualIssue 操作菜单中的Triage this issue条目仅阻塞该菜单选择它的人eventIssue 创建时自动触发从不阻塞插件本身schedule每五分钟UTC从不阻塞插件本身清单中两个 hook 的 trigger 声明与之一致triage_issue声明了[ui, manual, event]并监听issue.created事件scheduled_heartbeat只声明[schedule]且带cron: */5 * * * *、timezone: UTC的调度计划。两类场景各有一个典型连接点ui/manual是「人点了按钮要看到结果」所以阻塞对应的入口并把结果带回去event与schedule是主机在后台发起的自动行为不需要也不应该等人。关于触发身份README 强调了一个容易被忽视的原则event从不阻塞并不是性能调优的取舍。事件总线是在发布请求的 goroutine 上同步内联执行监听器的如果从这里拨出 hooktriage.example.com就会进入「创建 issue」这条关键路径——一旦别人的服务器响应慢issue 就会因为第三方服务缓慢而创建失败。因此事件触发的 hook 被设计为不可阻塞从ui面板调起同一 hook 的结果会回到面板而事件路径没有客户端在等待两者天然区分。运行处理器一条命令与四条硬性安全要求启动示例处理器只需一条命令签名密钥通过环境变量注入# 签名密钥只在管理员于工作区设置中轮换插件 token 时展示一次与安装 token 并列 MULTICA_SIGNING_SECRETwhsec_… node server/handler.mjs端口默认8787可用PORT覆盖若设置了MULTICA_HOOK_TLS_CERT与MULTICA_HOOK_TLS_KEY处理器会自动改用 HTTPS。handler.mjs 的注释开门见山这段代码不运行在 Multica 内而是作者自己运营的普通 HTTP 服务器Multica 只向它发送签名的 POST。安全相关的一切都在这一侧完成——这正是要点主机无法让一个处理器变得安全它只能给处理器提供做到安全所需的原料。README 把处理器必须做的四件事列得很清楚这也是本文最值得展开的部分。handler.mjs 的verify()函数server/handler.mjs逐条实现了它们对原始字节做校验先校验后解析。把 JSON 重新序列化后再签名是让合法签名失效、非法签名通过的经典方式——键的顺序与空白不会被保留。因此处理器先用Buffer.concat(chunks)收集原始 body再对rawBody做 HMAC 计算只有通过后才JSON.parse。检查时间戳。时间戳内嵌在签名里因此被捕获的请求会过期主机端的容忍窗口是五分钟。handler 侧的常量TOLERANCE_SECONDS 5 * 60与服务端plugin_hook.go中hookTimestampTolerance 5 * time.Minuteserver/internal/service/plugin_hook.go对应。处理器先比较x-multica-timestamp头与当前时间差是否超出窗口超出即拒。恒定时间比较。逐字节比较会泄露「猜对了多少个字节」这足以反推出剩余部分。因此校验用 Node 的crypto.timingSafeEqual完成并先比较长度避免非等长快速短路。记住窗口内的签名——这是主机无法替你完成的部分。时间戳把重放限制在几分钟内只有接收方可以通过拒绝自己已见过的字节来彻底堵上缺口。handler 用一个seenSignaturesMapserver/handler.mjs在容差窗口内记住每次出现的签名rememberSignature返回 false 即视为「this request was already delivered」。实现上是惰性清理处理新签名时顺手把超过窗口的旧记录删掉既不引入定时器也不让表无限增长。签名计算方式为HMAC-SHA256(secret, timestamp . rawBody)secret 由whsec_前缀剥离后的十六进制解码而来。任何缺失头、超窗、不匹配都会被verify返回描述性错误并以 401 拒绝处理器只把错误原因写进响应体配合失败时统一返回{error: triage failed}的 502——注释点明了原因handler 回什么都会成为主机侧分类问题的输入把内部错误文本回显出去等于把自己的配置泄露进别人的日志。回调一次性令牌、身份既成与它的正确使用姿势每次出站请求都携带callback_token与callback_url。handler.mjs 的callback()server/handler.mjs是标准用法对callback_url path发起带Authorization: Bearer callback_token的请求。服务端数据结构印证了这一设计hookRequestBody中的CallbackToken注释说明它比安装级 token 更窄、且与本次调用绑定——泄露它最多等于泄露几分钟内本就在用的 scope而非长期访问权server/internal/service/plugin_hook.go。同时HookInvocation携带了IssueID用于收窄回调令牌让「回答某个 issue 的 handler」无法用同一授权横跨整个工作区server/internal/service/plugin_hook.go。三个容易踩错的点README 与源码都给出了明确结论令牌有效期只有几分钟且本次 HTTP 请求返回即被吊销。它由某一个 Multica 服务器实例持有因此只应花在响应之前能完成的工作上。回调以什么身份写入不由你选择。这在 hook 分发时就已经决定ui/manual调用以触发者的身份写入并记录为via_plugin_id经插件发起event调用以插件身份写入。handler 不能选择冒充别人——这正是存在回调令牌而不是直接下发安装 token 的原因。异步/对账场景要用安装 token。需要异步继续或回填大量外部积压的定时集成应保存管理员在 token 轮换时看到的mpi_安装 token用那个长期凭据替代一次性回调令牌。请求体里的actor字段记录type与id处理器可用body.actor?.type区分身份来源示例日志即打印as ${body.actor?.type}。业务主流程server/handler.mjs演示了回调令牌足够支撑的「读一写一」两步先用 GET 取回 issue/issues/{id}跑一遍规则做分诊再 POST/issues/{id}/comments把建议写回评论。这里有一个重要的 host 行为值得强调issue 由主机先解析并完成权限检查后放入issue_id字段再发给你——它优先于input里的任何值因为事件触发根本没有客户端可提供该值而ui/manual下客户端提供的 id 也不应被 handler 信任。没有 issue 在作用域内时处理器返回{ skipped: no issue in scope }。定时投递的身份语义delivery_id、invocation_id 与 attemptscheduled_heartbeat这个 hook 被刻意设计为无副作用它只把稳定的delivery_id、每次尝试独立的invocation_id、attempt以及schedule.planned_at打日志后返回{ received }。服务端请求体结构server/internal/service/plugin_hook.go给出了这些字段的权威定义invocation_id每次出站请求都不同delivery_id在同一计划发生的重试间保持稳定schedule.planned_at是规范的 UTC cron 发生时刻而非投递的墙上时间HookInvocation的注释进一步说明ID在出站前就分配好即使调用失败接收方也能用日志把它与持久化的调用记录关联。README 据此给出对真实定时插件的硬性要求真正有副作用的定时 Hook 必须在执行副作用之前持久化delivery_id。原因很直白网络超时与过期租约恢复stale-lease recovery可能让同一计划发生被投递不止一次。这是分布式投递的至少一次语义靠接收方幂等来收敛——与前面「记住签名」是同一哲学的另一面主机能做的是让每次投递可辨认、可重试唯一能实现「恰好一次」的只有收到字节那一侧。出站边界一条net:就是全部答案README 用一句话概括了插件的出站能力边界清单里的net:triage.example.com就是全部答案且管理员在同意授权界面上看到的正是这一行原文。三条规则必须记牢精确主机匹配绝非后缀匹配。需要api.triage.example.com的插件必须单独声明net:api.triage.example.com。同一份列表同时成为面板 CSP 的connect-src。一份字符串在两个地方含义一致见 packages/plugin-sdk/README.md没有net:scope 时面板连回自己原域名的网络请求都会被策略拦下。此外注意net:声明的是精确主机net:api.example.com与net:example.com需要分别声明。部署侧无法放宽它。MULTICA_PLUGIN_DEV_ORIGINS只允许作者在开发期把 hook 指向本机服务器但net:检查依然执行——操作员能放松的是「网络防护作用在哪里」而不是「管理员批准了什么」。服务端实现印证了这套模型plugin_hook.go 在拨号前要求目标必须在已同意的net:集合内没有net:scope 直接以PluginErrorForbidden拒绝this Plugin was granted no net: scope未覆盖的目标也返回 forbidden。注释特别指出同意检查不负责这类约束不在已授权net:集合内的目标无论清单如何声明都会被拒绝。这也从机制上解释了为什么示例的 SSRF 防护能把「拒绝私有地址」与「放行已同意域名」分开处理。本地开发把 hook 指向 localhost 的正确姿势开发联调只需两步README 给了起点export MULTICA_PLUGIN_DEV_ORIGINShttps://localhost:8787然后在清单里把transport.url指向本地 handler并声明匹配的net:localhostscope。没有这个显式 opt-in主机拒绝拨号私有地址——那是 SSRF 防护在正常工作。环境变量的解析实现在 server/internal/service/plugin.goparseDevOrigins其原始定义位于 server/pkg/remotemcp/devorigin.goisDevOrigin对 scheme host port 做精确匹配——前缀或后缀匹配会让http://127.0.0.1:9000连带放行http://127.0.0.1:9000.example.comDevOriginsEnv由 server 与 daemon 两个进程分别读取所以每次调用现查环境变量而非进程启动时缓存。该文件还揭示了 HTTPS 的取舍本地跑的 hook 服务器仍然必须讲 HTTPS——清单校验器强制要求而「开发豁免」的形态是「信任额外的 CA」MULTICA_PLUGIN_DEV_CA绝不是「跳过证书验证」读不到或读错 CA 文件会返回 nil 从而走默认校验属于 fail-closed。相应地示例 handler 在同时提供 cert/key 时自动从 http 切换为 https 监听server/handler.mjs因为运输 URL 必须是 HTTPS纯 HTTP 的处理器即使在开发期也无法被指向。小结一条值得复用的插件安全基线把四条安全要求放在一起看triage-notify 实际上沉淀了一条可复用的出站插件安全基线先验原始字节再解析 → 校验时间戳防过期重放 → 恒定时间比较防时序侧信道 → 记住窗口内签名补上主机替不了的最后一块重放缺口回调只用一次性令牌、且只用于响应前的工作定时逻辑对delivery_id幂等出站域名以精确net:声明并同时约束面板 CSP。仓库中的同族示例deploy-sentinel、schedule-pulse也演示了相同模式的变体例如MULTICA_PLUGIN_DEV_ORIGINShttps://127.0.0.1:8788,https://127.0.0.1:8789的多域名开发配置可一并对照阅读。若从零实现一个带后端的 Multica 插件最直接的做法就是复制triage-notify三个文件把triage.example.com换成你自己的域名再按上文核对一遍四件事是否全部落在你的处理器里。【免费下载链接】multicaMake humans and AI agents work as one team — open-source and self-hostable.项目地址: https://gitcode.com/GitHub_Trending/mu/multica创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考