Motrix MDXP 桥接层深度解析:JSON-RPC 2.0 协议边界、配对授权与多传输通道实现
Motrix 通过一个名为 MDXP 的桥接层(bridge),让浏览器扩展、同机 CLI/Agent 与下载引擎之间以 JSON-RPC 2.0 协议通信。本文基于仓库内的桥接规则文档与核心源码,完整梳理 Motrix 桥接层的连接入口、配对(pairing)与会话生命周期、授权/就绪两态模型、方法暴露策略、Native Messaging 宿主以及配套的验证手段,读完你可以掌握这套“传输中立 + 边界校验 + 显式授权门”的设计,并能对照源码理解每个安全决策的落点。
一、协议总览:@motrix/mdxp 是唯一事实来源
Motrix 的桥接层使用 JSON-RPC 2.0 over WebSocket 和 HTTP 两种传输。仓库对协议依赖的边界定得非常死:
- 线上 wire schema、方法常量、类型、错误码与连接行为,一律以
@motrix/mdxp包为准,其版本记录在 package.json 中(当前为^0.5.0)。 - 仓库内禁止重复实现 MDXP schema、数字错误码,或已移除的旧版帧协议;错误对象必须通过
import { ErrorCodes, makeMdxpError } from '@motrix/mdxp'构造。 - 桥接相关的核心代码分布在四个目录:
src/core/bridge/(协议核心,传输中立)、src/core/bridge-receiver/(扩展接收管线)、src/main/bridge/(Electron 主进程壳)、src/server/bridge/(Node 服务端壳)。
这一分工在 MdxpDispatcher 的注释中写得很直白:WebSocket 传输与一元 HTTP 传输都注册并调用同一个 dispatcher,因此方法面(method surface)只定义一次。
二、连接入口:两条 WebSocket 路由与一组 HTTP 端点
服务端全部入口集中在 WebSocketBridgeServer。它同时是一个 Node HTTP server 和一个 noServer 模式的 WebSocketServer,在构造函数中挂接了 upgrade 与 request 两类事件。
2.1 WebSocket:/pair 与 /v1
有效的 MDXP 客户端在任一路线上,第一个请求都必须是 motrix/initialize。/v1 的 token 在 upgrade 阶段已经过校验,因此升级成功即“已授权”;/pair 的首次连接则尚未授权,必须等 initialize 处理器记录一次“配对批准”后才被 markAuthorized()(见 attachConnection)。
upgrade 阶段有三道硬性检查(源码 L197-L229):
- Origin 必须是扩展源:正则要求
chrome-extension://或moz-extension://前缀,否则直接回401; - 子协议必须声明:
Sec-WebSocket-Protocol头中必须包含motrix-bridge.v1,否则401; - 路径白名单:只接受
/pair与/v1,其余路径404。
/pair 路线额外要求一次性 nonce 有效(见 handlePairUpgrade),并携带 extensionId、browser(必须是 chromium 或 firefox)、extensionName、extensionVersion 参数;/v1 路线则通过 handleV1Upgrade 用 pairing.findByToken() 验证 token,且只接纳 extension 身份——CLI/Agent 永远走一元 HTTP 传输,不走这条 socket。
2.2 HTTP:nonce 发放、SSE 事件流、一元 JSON-RPC、设备码配对
HTTP request 处理器暴露了以下端点(源码 L231-L305):
| 端点 | 作用 | 鉴权 |
|---|---|---|
GET /nonce |
为 Native Messaging 宿主发放一次性配对 nonce | 仅回环地址可达 |
GET /mdxp/events |
SSE 事件流($/task/*、$/stats 全局推送,供 CLI watch) |
Bearer token |
POST /mdxp |
无状态一元 JSON-RPC 2.0(同机 CLI/Agent) | Bearer token + agent-facing 门 |
POST /mdxp/pair/request |
设备码配对:发起请求 | 设计上不鉴权(新 CLI 尚无 token),由 UI 审批把关 |
POST /mdxp/pair/poll |
设备码配对:轮询审批结果/取 token | 高熵 requestId 即能力凭证 |
其中几个值得注意的实现细节:
- SSE 心跳与吊销联动:
SSE_HEARTBEAT_MS = 15_000,心跳定时器unref()以免拖住进程退出。更关键的是,PairingService的revoked/rotated事件会直接触发 closeSseForIdentity,把对应身份的所有打开中的 SSE 流关闭——仅连接时校验 token 的 SSE 会泄漏整个“消防水龙”(firehose),这是文档中“已认证的 SSE 流必须在配对 token 被吊销时关闭”这条规则的具体落地。 - 一元请求体上限:
MAX_UNARY_BODY_BYTES = 4MB(为download/add的 torrent base64 留足空间),pair/request与pair/poll的上限为4KB,超限统一413。 - 绑定即失败关闭(fail closed):start() 默认绑定
127.0.0.1+ 临时端口(桌面端行为,端口写进endpoint.json);若传入非回环 host 而没有配置localToken,直接抛错拒绝启动,绝不把未认证的桥接面暴露到局域网。
2.3 endpoint.json:发现文件与最小暴露
EndpointFileWriter 在桥接启动时写一个 { port, pid, writtenAt, localToken } 的小 JSON(如 ~/Library/Application Support/Motrix/bridge/endpoint.json),供 Native Messaging 宿主发现端口、供同机 CLI 发现端口与机器主 token。由于 localToken 是 Bearer 机密,文件同时用 writeFile 的 mode: 0o600 和无条件 chmod(0o600) 双保险保证其他用户不可读(writeFile 的 mode 只在创建时生效,不清理后的旧文件会保留旧权限);该文件从不被打印进日志。
三、配对与会话生命周期:授权(authorized)与就绪(ready)是两个状态
这是整份桥接规则的核心洞见:“已授权”不等于“已就绪”。BridgeConnection 为每条 WebSocket 连接维护两个独立标志:
authorized —— 对端被允许调用控制面/下载方法
/v1 重连:upgrade 时 token 已验证 → 连接即授权
/pair 首连:初始未授权,initialize 批准配对后才 markAuthorized()
ready —— 对端已发送 `motrix/initialized` 通知
只有 ready 的连接才能接收服务端主动发起的
url/probe / url/resolve 请求
3.1 授权门:authorizedDispatch
MdxpDispatcher 本身没有授权概念,因此门控做在接线层。applyHandlers 为每个连接生成一个 authorizedDispatch 包装:除 motrix/initialize 与 system/ping 外的所有方法,都先检查 ctx.isAuthorized(),未授权则拒绝并抛出 makeMdxpError(PermissionDenied, 'session is not authorized; complete pairing first', { appCode: 'pair.required' })。源码注释点出了这条门的攻击场景:“没有这道门,仅仅消费掉一次性 nonce 的调用方就能在用户批准之前(甚至完全不批准)驱动 download 与控制面方法。”
3.2 motrix/initialize:唯一的身份握手
motrix/initialize 由 WebSocketBridgeServer 构造函数始终注册(源码 L168-L179),壳层提供的下载类处理器才通过 setHandlers() 另行注册,且必须在 server.start() 之前完成接线——不允许暴露监听器不完整的服务。initialize 处理器 对两类连接行为不同:
/v1重连(ctx.pendingPair === null):客户端必须重新声明与已配对 token 相同的身份——params.client.extensionId必须等于会话身份的extensionId,否则抛InvalidParams;并且对client.kind/session.kind均显式校验为extension(注释强调“fail closed”,防止未来签发 cli/agent token 后绕过该检查)。通过后返回能力信息(含 Motrix 版本、runtime、ffmpeg 可用性),不带新 token。/pair首次配对:调用onPairRequest弹出配对对话框(桌面端由 UI 审批);用户拒绝则抛PermissionDenied(appCode: 'pair.denied');批准则经 PairingService.issueToken 铸造 token 并在结果中返回,同时ctx.markAuthorized()解锁该连接。
3.3 一次性 nonce:60 秒、只在 /pair 消费
nonce 的实现见 issuePairNonce / consumeNonce:randomBytes(16) 的 base64url 编码,TTL 为 PAIR_NONCE_TTL_MS = 60_000。消费时先 delete 再校验未过期,保证一次性语义;每次签发前先清扫过期条目,避免被拒绝/放弃的配对尝试让 Map 无界增长。规则明确要求:nonce 永不持久化。
3.4 会话所有权:只删自己
连接关闭处理器(源码 L924-L929)先比对 sessions.get(sessionKey) === session 才删除——即只移除“当前”会话,防止后连接的合法会话被先关闭的旧 socket 误删,随后 conn.dispose() 释放 reader/writer/连接。文档对应规则是:特性代码不得直接改动 server 的 session map。
另外 PairingService 的 token 是 randomBytes(32) base64url,重配对走“先持久化、后改内存索引”的顺序并触发 rotated 事件(旧 token 立即失效,SSE 随之关闭);markActive 的落盘写入按 60 秒窗口节流,避免高频 CLI 请求每次都写磁盘。
四、MdxpDispatcher:边界校验与传输中立的单点
mdxp-dispatcher.ts 只有 70 行,却承载了“单一校验源”的职责:
// dispatch(method, rawParams, ctx):
// 1. 未注册方法 → makeMdxpError(CapabilityNotSupported, `unknown method: ...`)
// 2. entry.schema.safeParse(rawParams) 失败
// → makeMdxpError(InvalidParams, ... , { context: { method, issues } })
// 3. 通过 → handler(parsed.data, ctx) // handler 收到的已是类型化参数
由此确立的职责边界:handler 与 BridgeReceiver 收到的都是已类型化的数据,只负责业务规则,不做参数解析。
传输中立性的另一处落点是 web-socket-message-stream.ts:WebSocketMessageReader/Writer 把任意匹配 WebSocketLike 最小接口的对象适配成 vscode-jsonrpc 的 MessageReader/MessageWriter,使 MessageConnection 可以跑在 WebSocket 上。注释明确说明设计动机——避免 core 对 ws 类型产生硬依赖(“core 是引擎中立的,引 ws 是架构异味”):@core/bridge/ 下唯一允许在生产代码中 import ws 的文件是 web-socket-bridge-server.ts,其余文件一律通过 WebSocketLike 交互。同时,每个 WebSocket 帧就是一个完整的 JSON-RPC 2.0 消息,没有 Content-Length 分帧——WebSocket 协议本身已处理消息边界。
跨传输的每请求上下文由 MdxpSessionContext 抽象:WS 会话提供完整的 sendRequest/sendNotification,而无状态一元路径合成一个 pendingPair: null、无出站能力的上下文,服务端主动发起方(如 URL 解析服务)必须对出站能力做存在性守卫。
五、方法暴露策略:注册 ≠ 可达,三条显式门
规则文档强调:不要假设 dispatcher 注册了就等于在每种传输上可达。新增一个 MDXP 方法的标准流程是:
- 在 MDXP 仓库定义并发布 schema 与方法常量,再升级本仓库依赖;禁止提交机器本地的
link:/file:依赖; - 在 dispatcher 注册 schema + 类型化 handler。
setHandlers()只用于壳层提供的扩展方法;共享控制面方法放在registerReadHandlers/registerWriteHandlers(对应 registerReadMethods / registerWriteMethods); - 显式选择并测试暴露面:一元 HTTP、扩展 WebSocket、或两者;
- 客户端通知注册
conn.onNotification;出站通知经BridgeEventBus发布、由壳层调conn.sendNotification(Notifications.*, params);可取消的出站请求走领域服务并传CancellationToken。
三条门的具体形态:
门 1:扩展 WebSocket 控制面白名单。 EXTENSION_WS_CONTROL_PLANE 常量精确列出已配对扩展可在 WS 上驱动的方法:task/list、task/get、task/pause、task/resume、task/remove、stats/get、engine/status。每个方法接线前都要过 dispatcher.has() 检查——壳层没注册的方法自然缺席,形成“注册 + 白名单”双门。
门 2:download/add 一元独占。 文档规定 download/add 只走一元 POST /mdxp,扩展提交下载必须用 download/submit。源码注释解释了动机:扩展经 WS 走 download/submit(由壳层 setHandlers 注册),download/add 面向 Agent 侧 CLI,二者能力面不同;此外,仅供渲染进程的复数任务命令不得进入 MDXP。
门 3:一元 HTTP 的 agent-facing 门。 handleUnaryMdxp 在鉴权(resolveBearer 只接受机器主 localToken 或设备码配对的 cli token,扩展 token 在此被拒,未知 token 失败关闭)之外,还检查 Tools[method]?.agentFacing——dispatcher 里注册了但没标记 agent-facing 的方法(如扩展握手/提交类方法)在 HTTP 上直接 404 CapabilityNotSupported。这正是“dispatcher 注册不能使扩展方法在 HTTP 上可达”的落点。
一元路径还有一套完整的错误码 → HTTP 状态映射(httpStatusForUnaryCode):
| MDXP 错误码 | HTTP 状态 |
|---|---|
InvalidParams |
400 |
PermissionDenied / PairRevoked |
403 |
CapabilityNotSupported / ResourceUnavailable |
404 |
RateLimited |
429 |
| 其他 | 500 |
内部 AppError(如 TaskNotFound)也会被翻译为对应 MDXP 码(appErrorToMdxpCode),避免核心层字符串错误码塌缩成不透明的 500。
六、服务端主动请求:URL 解析与取消控制
渲染进程发起的 URL 解析经 BridgeQueries.* IPC 进入主进程,主进程再通过 MDXP 的 url/probe / url/resolve 向**已就绪(ready)**的扩展发请求。UrlResolutionService 采用“首个成功 probe 胜出”策略:probe() 与 resolve() 共享 findHandlingSession() 的迭代与短路逻辑,保证两者永不出现分歧;probe 失败静默跳过、继续下一会话;无任何会话时报 CapabilityNotSupported(appCode: 'bridge.no_session'),无适配器处理该 URL 时报 ResourceUnavailable(appCode: 'bridge.no_adapter')。文档中“传给 UrlResolutionService 的 provider 必须过滤到 ready 连接”对应的正是这里的 getSessions() 注入点——调用方(壳层)负责只传入 isReady() 为真的连接。
取消语义遵循“请求方给 ID、主进程持有取消源”:渲染进程提供请求 ID,主进程持有自己的 CancellationTokenSource,配对的取消查询负责 abort;resolve() 把 CancellationToken 透传给 sendRequest('url/resolve', params, token)。
七、Native Messaging 宿主:一个“只有 port”的 Rust 执行文件
packages/native-host/ 是一个由 Chromium 或 Firefox 拉起的独立 Rust 可执行文件,职责被刻意压到最小:
- 从 stdin 读一条带超时(
INPUT_TIMEOUT)的 Native Messaging 消息,解析出allowLaunch; - 调用
resolve_direct():读取endpoint.json的端口(EndpointFile 只反序列化port字段,文件上限 64KB,localToken直接被 serde 忽略——宿主不暴露、不读取本地 CLI token); - 对回环地址探测
GET /nonce拿到一次性 nonce,向 stdout 写回{ action: 'requestPair', port, nonce }(ResolveResult); - 失败路径返回三种错误:
motrix-not-running(endpoint 无活进程且不允许拉起)、motrix-not-installed(launcher 失败)、motrix-launch-failed(拉起后 15 秒内轮询不到新 endpoint)。
时序参数同样在源码中可验证:探测超时 PROBE_TIMEOUT = 1s;允许拉起时的轮询窗口 LAUNCH_POLL_TIMEOUT = 15s、间隔 200ms,且采用绝对截止时间而非“再试 N 次”,防止末次探测超发时间预算(resolve.rs 测试 中有 launch_poll_uses_absolute_deadline_without_final_overshoot 用例锁定该行为)。宿主不依赖系统 Node.js 或 Electron,一次性执行后退出;main.rs 还会把超限的尾部 stdin 数据直接吞掉并记录,防止对端向这个 one-shot 宿主灌入无界数据。
可信扩展身份的来源分生产与开发两条线:
- 生产扩展 ID 固化在 native-messaging-extensions.json:
chromium与firefox两个浏览器各有对应 ID(Chromium 侧 ID 形如 32 位a-p小写字母,Firefox 侧为name@domain或 GUID,TrustedExtensionRegistry 用正则校验两种格式); - 开发期 ID 只能通过
MOTRIX_DEV_TRUSTED_EXTENSIONS注入,绝不可作为生产默认值进入仓库。
主进程侧的 BridgeManager 用一条串行化队列管理桥接运行时:每次启用都重新调用 factory(新端口、新 registry、同步一次 Native Messaging 清单),启动失败时级联注销 Native Messaging 注册并聚合两个错误;setEnabled(false) 则停止运行时并注销注册。
八、验证:scoped 检查与端到端验收
规则文档给出的验证方式是:先跑全局质量门,再跑桥接范围的检查,且不编码固定的测试数量:
pnpm exec biome check src/core/bridge/ src/core/bridge-receiver/ src/main/bridge/ src/server/bridge/
pnpm exec vitest run src/core/bridge/ src/core/bridge-receiver/ src/main/bridge/ src/server/bridge/
仓库内可用的配套验证材料:
- 协议层单测:web-socket-bridge-server.authz.test.ts(授权门)、web-socket-bridge-server.stop.test.ts(优雅停机:先停 dispatcher 入口、再关 SSE、
terminate()全部 WebSocket 后http.closeAllConnections()兜底——这段注释还记录了它修复“第一次 Cmd+Q 看似无反应”的根因); - Rust 宿主测试:resolve.rs 的
#[cfg(test)]模块用假依赖验证“活 endpoint 不拉起进程”“allowLaunch=false绝不拉起”“轮询读到新端口与新 nonce”等行为; - 真实端到端:e2e/bridge/README.md 描述了用发布版
@motrix/cli对 Electron 与 Node server 两个壳跑“pair → approve → token →download/add→ completed → 落盘字节 → watch SSE”全链路,外加对抗性探针(错误 token 退出码、token 一次性交付、deny 路径等)。
小结
Motrix 桥接层值得借鉴的是一套“显式边界”工程:协议常量不重复定义(@motrix/mdxp 单一事实来源)、校验只发生在 dispatcher 边界(handler 只见类型化数据)、传输依赖被隔离在唯一文件(WebSocketLike 抽象)、授权与就绪拆成两个状态各自有门(authorizedDispatch 与 ready 过滤)、每种传输的方法可达性逐一显式声明(白名单 + has() + agentFacing)、机密文件用最小字段与 0600 权限暴露(endpoint.json 对宿主只露 port)。配合一次性 nonce、吊销即关流、非回环绑定必须带 token 等 fail-closed 决策,这套桥接把“谁能在哪条传输上调哪个方法”变成了可以在源码中逐行核对的事实,而不是散落在各处的隐式约定。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0625
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00