DSH Link Protocol (DLP) v1
September 20, 2026 · View on GitHub
面向:实现者与自建中转的部署方 · 状态:stable(DLP v1) · 最近核对:2026-09-20
规范版本:1
状态:draft-1(已按本规范实现 relay / agent / iOS 客户端)
DLP 解决的核心问题:电脑端 DSH 没有公网 IP。电脑侧连接器(agent)主动外连中转服务器,
手机(device)也连中转服务器,由中转按 agentId 配对并转发帧。
设计目标:
- 不重新定义 DSH 语义。DLP 只是 DSH 原生协议(HTTP
/api/<method>+ WS/api/remote.mux) 的多路复用隧道。转发层不认识 session、不解析事件,因此 DSH 升级不会破坏 DLP。 - 单连接多路复用。手机与电脑之间只有一条 WSS,承载全部一元 RPC、全部逻辑流、以及宿主事件。
- 多设备 / 多用户可扩展。同一 agent 可被多台设备连接;中转按账号隔离,为后续上架预留。
1. 拓扑
iOS App (device) Relay (公网) PC (agent)
│ │ │
│ WSS /link/device │ WSS /link/agent │
├─────────────────────────────►│◄───────────────────────────┤
│ Bearer <deviceToken> │ Bearer <agentToken> │
│ │ │
│ │ ┌──────┴───────┐
│ │ │ dsh web │
│ │ │ 127.0.0.1:P │
│ │ └──────────────┘
Relay 只做两件事:鉴权与按 agentId 转发。它不解析 DLP 之外的任何 DSH 语义。
2. 端点
| 方法 | 路径 | 说明 |
|---|---|---|
GET | /healthz | 健康检查,返回 {"ok":true,"version":1} |
WS | /link/agent?agentId=<id> | 电脑侧连接器接入;Authorization: Bearer <agentToken> |
WS | /link/device?agentId=<id> | 手机侧接入;Authorization: Bearer <deviceToken> |
POST | /pair/claim | 手机用配对码换取 deviceToken |
POST | /pair/refresh | 刷新 deviceToken(长期使用) |
OPTIONS | * | CORS 预检 |
agentId 也允许放在首帧 hello 中;query 参数优先。
2.1 /pair/claim
请求:
{ "pairCode": "7F3K-9Q2M", "deviceName": "Example iPhone", "deviceModel": "iPhone17,1", "appVersion": "1.0.0" }
响应 200:
{ "ok": true, "agentId": "agt_...", "deviceToken": "dt_...", "accountId": "acc_...",
"agentName": "Example 的 MacBook Pro", "expiresAt": 1790000000000 }
失败:{"ok":false,"error":{"code":"pair/invalid-code","message":"..."}}
- 配对码一次性、有时效(默认 10 分钟),由电脑侧连接器生成并展示(二维码 / 终端)。
deviceToken长期有效(默认 365 天),并随/pair/refresh轮换。
2.2 鉴权失败
WS 升级阶段失败直接回 HTTP 401 / 403(不进入 WS 状态机)。
进入 WS 后鉴权失败回 {"t":"error","code":"auth/...","fatal":true} 并关闭。
3. 帧格式
- 传输:WebSocket 文本帧,UTF-8 JSON 对象。
- 每条帧必须有
t(string)。 - 未知
t必须忽略(前向兼容)。 - 二进制帧保留给附件传输(v2)。
3.1 device → agent
t | 字段 | 语义 |
|---|---|---|
req | id, method, args | 一元 RPC。method 为 DSH 端点名(如 session/list),args 为 DSH 命名字段对象 |
open | id, endpoint, args | 打开逻辑流(如 session/follow、$events) |
cancel | id | 取消逻辑流 |
eventResult | id, result | 回应宿主 $events/result(waterfall 应答) |
ping | ts | 心跳,agent 回 pong |
3.2 agent → device
t | 字段 | 语义 |
|---|---|---|
res | id, ok, value? / error? | 一元 RPC 响应 |
item | id, value | 逻辑流数据项 |
end | id | 逻辑流正常结束 |
streamError | id, error | 逻辑流失败 |
event | value | 宿主事件(来自 $events 流) |
hostStatus | info | agent/DSH 状态变化(上线、离线、版本、工作区) |
pong | ts | 心跳回应 |
error | code, message, fatal? | 协议级错误 |
id 为 device 生成的字符串,唯一标识一次 RPC 或一条逻辑流。agent 必须原样回填。
error 对象统一为 {"code":string,"message":string,"details":object},与 DSH 的失败形状一致。
3.3 示例
手机列出会话:
{"t":"req","id":"1","method":"session/list","args":{"_request":{}}}
{"t":"res","id":"1","ok":true,"value":{"items":[...]}}
手机订阅某会话实时事件:
{"t":"open","id":"2","endpoint":"session/follow","args":{"request":{"agentId":"session-...","afterSeq":3883}}}
{"t":"item","id":"2","value":{"type":"event","event":{...}}}
{"t":"item","id":"2","value":{"type":"assistant-stream","frame":{...}}}
手机回答宿主的交互提问:
{"t":"eventResult","id":"3","result":{"clientId":"ae3f...","eventId":"5e51...","outcome":{"kind":"result","value":{"answers":[...]}}}}
4. agent 行为规范
4.1 与本地 DSH 的对接
- 发现端点:读取
$DSH_HOME/desktop-shell/endpoint.json的url/port; 若不可用则回退$DSH_WEB_URL、再回退http://127.0.0.1:54499。 端点可能因 DSH 重启而变化,每次重连都要重读。 - 认证换取:
GET <base>/?token=<token>,不要跟随重定向,从Set-Cookie取dsh-auth-*,后续所有请求带该 Cookie。token 失效(401)时重读 endpoint.json 并重试一次。- 注意:DSH 的认证 Cookie 绑定 authority(host:port),因此 agent 必须始终以
127.0.0.1:<port>这个 authority 访问,不得改写 Host。
- 注意:DSH 的认证 Cookie 绑定 authority(host:port),因此 agent 必须始终以
- 一元 RPC:
POST <base>/api/<method>, body{"type":"client-request","rpcId":"<id>","method":"<method>","payload":{"args":<args>}}, 响应{"type":"server-response","rpcId":"<id>","result":{"ok":true,"value":...}|{"ok":false,"error":{...}}}。 agent 把result直接映射为 DLP 的res。 - 逻辑流:agent 维护一条到
<ws-base>/api/remote.mux的 WebSocket,承载所有设备的 所有逻辑流。device 的id↔ muxstreamId一一映射。- 打开:
{"type":"open","streamId":"<muxId>","endpoint":"<endpoint>","payload":{"args":<args>}} - 上行收到
item/error/end后转成对应 DLP 帧。 - 设备
cancel→ 发{"type":"cancel","streamId":"<muxId>"}。
- 打开:
- 宿主事件:agent 在 mux 上额外打开
$events({"args":{}}),把每条item以{"t":"event","value":<item>}广播给所有已连接设备。 其中ready帧携带clientId,每个 device 需要自己的 clientId:$events/result的clientId必须与开启该流时收到的ready.clientId一致。 → 因此 agent 为每个 device 单独打开一条$events流。 - waterfall 去重:同一个
eventId的 waterfall 会推给多台设备。首答生效, 其余丢弃(agent 按eventId记录已答集合,TTL 清理)。
4.2 可靠性
- 心跳:device 每 20s 发
ping;agent 每 20s 也主动pingrelay。pong超时 60s 判定断线。 - 重连:指数退避
1s → 2s → 4s ... 上限 30s,加 ±20% 抖动。 - device 断开时,agent 必须
cancel该 device 拥有的全部逻辑流,避免泄漏。 - agent 重启后
agentId/agentToken保持不变(持久化到$DSH_HOME/mobile-link/agent.json)。
5. relay 行为规范
- 维护
agentId → agentConnection注册表;同一agentId重复接入时新连接顶掉旧连接。 - device 连上时若 agent 不在线,回
{"t":"hostStatus","info":{"online":false}}, 并在 agent 上线时补发{"t":"hostStatus","info":{"online":true,...}}。device 无需轮询。 - 转发时保留原帧,不改写
id。 - 帧大小上限 32 MiB(对齐 DSH 的图片附件上限)。
- 单 device 未确认帧上限(背压)512 条,超限断开防止内存膨胀。
- device 断开:通知 agent 清理该 device 的流。
5.1 共享带宽下的三种限制(可选,默认全关)
中转通常部署在固定带宽的主机上,而那条带宽由所有设备共享。以下三项由运维按主机 带宽开启,协议本身不要求它们存在;device 与 agent 都必须能容忍它们生效:
| 限制 | 默认 | 生效时的可观测行为 |
|---|---|---|
| 单设备出口限速 | 关 | 设备不会掉帧,只是收得慢:relay 在写侧限速,必要时把一个大帧拆成多个 WebSocket 消息(通道是流,客户端照常重组) |
| 单设备每日(UTC)出口额度 | 关 | 超额时先收到 {"t":"error","code":"quota/device-daily","details":{"limitBytes":N}},随后连接以 4011 关闭;次日 UTC 零点后可重新连接 |
| 每 agent 设备数上限 | 关 | 超出时 device WebSocket 在升级前被拒(HTTP 403)。同一 deviceId 的重连不占第二个名额,所以手机不会把自己锁在外面 |
relay 的关闭码(应用区间):
| 码 | 含义 |
|---|---|
4001 | 同一 agentId 的新连接顶掉了旧的 |
4008 | 背压:该 device 未取走的帧超过 512 条 |
4009 | 保留:限速相关的断开(当前实现用限速而非断开,故未使用) |
4010 | agent 不在线或已饱和 |
4011 | 单设备每日额度用尽 |
运维侧的实时数据:GET /stats(仅回环,不属于协议)给出当前 agent/设备数、生效中的
限制、以及每个设备本次启动以来的出口字节数与被限速时长。主机的网卡计数器回答不了
「是哪台设备在用带宽」——它混进了 SSH 与 OTA 下载;这份数据是 relay 自己算的。
6. 局域网直连(历史路径,已不在产品里)
早期版本把「手机与电脑同一局域网时直连」当作优先路径(性能最好、不消耗中转流量)。
产品上已经去掉:只保留经公网中转一条连接方式,不给用户选(理由见 PAIRING.md)。
残留的实现只有一处——App 里 DEBUG-only 的 dsh://direct 测试通道,只给仿真器自动化用,
不出现在任何用户界面里。
这一段保留下来是因为协议层仍然支持它:LinkCarrier 的传输抽象不关心对端是本地 DSH
还是中转;要做局域网直连只需要另一个 carrier 实现,不需要改帧格式。
7. 中转侧 DSH 配置
本节的前提不成立,实现未采用。 见
notes/relay.md§1。
本节假设 DSH 看到的 Host 是中转域名。实际上 agent 直接连
127.0.0.1:<port>,并且不改写 Host(§4.1.2 也要求如此,认证 Cookie 绑定
authority),所以 trust fence 看到的是回环地址、直接放行:
dsh web # 不需要 --trusted-host
agent 改为在启动时校验真正要紧的三件事:重读 endpoint.json、完成
token → cookie 交换、失败时把原始错误写进 GET /mobile-link/status 的 dsh.error
与日志。若通过 dshUrl 显式指定了非回环地址,则按指定值处理。