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 配对并转发帧。

设计目标:

  1. 不重新定义 DSH 语义。DLP 只是 DSH 原生协议(HTTP /api/<method> + WS /api/remote.mux) 的多路复用隧道。转发层不认识 session、不解析事件,因此 DSH 升级不会破坏 DLP。
  2. 单连接多路复用。手机与电脑之间只有一条 WSS,承载全部一元 RPC、全部逻辑流、以及宿主事件。
  3. 多设备 / 多用户可扩展。同一 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字段语义
reqid, method, args一元 RPC。method 为 DSH 端点名(如 session/list),args 为 DSH 命名字段对象
openid, endpoint, args打开逻辑流(如 session/follow$events
cancelid取消逻辑流
eventResultid, result回应宿主 $events/result(waterfall 应答)
pingts心跳,agent 回 pong

3.2 agent → device

t字段语义
resid, ok, value? / error?一元 RPC 响应
itemid, value逻辑流数据项
endid逻辑流正常结束
streamErrorid, error逻辑流失败
eventvalue宿主事件(来自 $events 流)
hostStatusinfoagent/DSH 状态变化(上线、离线、版本、工作区)
pongts心跳回应
errorcode, 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 的对接

  1. 发现端点:读取 $DSH_HOME/desktop-shell/endpoint.jsonurl/port; 若不可用则回退 $DSH_WEB_URL、再回退 http://127.0.0.1:54499。 端点可能因 DSH 重启而变化,每次重连都要重读
  2. 认证换取GET <base>/?token=<token>不要跟随重定向,从 Set-Cookiedsh-auth-*,后续所有请求带该 Cookie。token 失效(401)时重读 endpoint.json 并重试一次。
    • 注意:DSH 的认证 Cookie 绑定 authority(host:port),因此 agent 必须始终以 127.0.0.1:<port> 这个 authority 访问,不得改写 Host。
  3. 一元 RPCPOST <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
  4. 逻辑流:agent 维护一条<ws-base>/api/remote.mux 的 WebSocket,承载所有设备的 所有逻辑流。device 的 id ↔ mux streamId 一一映射。
    • 打开:{"type":"open","streamId":"<muxId>","endpoint":"<endpoint>","payload":{"args":<args>}}
    • 上行收到 item/error/end 后转成对应 DLP 帧。
    • 设备 cancel → 发 {"type":"cancel","streamId":"<muxId>"}
  5. 宿主事件:agent 在 mux 上额外打开 $events{"args":{}}),把每条 item{"t":"event","value":<item>} 广播给所有已连接设备。 其中 ready 帧携带 clientId每个 device 需要自己的 clientId$events/resultclientId 必须与开启该流时收到的 ready.clientId 一致。 → 因此 agent 为每个 device 单独打开一条 $events 流。
  6. waterfall 去重:同一个 eventId 的 waterfall 会推给多台设备。首答生效, 其余丢弃(agent 按 eventId 记录已答集合,TTL 清理)。

4.2 可靠性

  • 心跳:device 每 20s 发 ping;agent 每 20s 也主动 ping relay。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保留:限速相关的断开(当前实现用限速而非断开,故未使用)
4010agent 不在线或已饱和
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/statusdsh.error 与日志。若通过 dshUrl 显式指定了非回环地址,则按指定值处理。