dsh-plugin-mobile-gateway 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/PROTOCOL.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # dsh Mobile Gateway — WebSocket 协议参考
2
2
 
3
- 移动端通过一个经过设备鉴权的 WebSocket 连接与 dsh 通信:订阅 agent 实时输出、发送消息、查询会话/工作区/历史、调整会话配置。本协议由持久化插件 `dsh-plugin-mobile-gateway` 实现(v0.3.0)。
3
+ 移动端通过一个经过设备鉴权的 WebSocket 连接与 dsh 通信:订阅 agent 实时输出、发送消息、处理 Human-in-the-loop 提问、查询会话/工作区/历史、调整会话配置。本协议由持久化插件 `dsh-plugin-mobile-gateway` 实现(v0.5.0)。
4
4
 
5
5
  - **本机端点**:`ws://127.0.0.1:3080/ws/mobile`(与 dsh web GUI 同端口)
6
+ - **局域网端点**:`ws://<电脑的私有局域网 IP>:3081/ws/mobile`(插件独立监听,只提供经过鉴权的 WebSocket)
6
7
  - **公网端点**:必须由 TLS 反向代理提供 `wss://<域名>/ws/mobile`
7
8
  - **帧格式**:全部为 JSON 文本帧(UTF-8)
8
9
  - **连接即推送**:连上后服务端立刻发送一条 `hello`,之后 agent 输出以 `event` 帧实时推送
@@ -43,12 +44,12 @@ WebUI 中有两个互相独立的开关。它们是本机管理设置,iOS 客
43
44
  |---|---|---|
44
45
  | 关闭 | 任意 | WebSocket Upgrade 返回 `503 Service Unavailable` |
45
46
  | 开启 | 开启(默认) | 必须使用一次性配对码或长期设备 token,否则返回 `401 Unauthorized` |
46
- | 开启 | 关闭(仅 Debug) | 允许无凭证连接,`hello.authenticated` 为 `false` |
47
+ | 开启 | 关闭(仅 Debug) | 仅 DSH 本机监听允许无凭证连接,`hello.authenticated` 为 `false`;独立局域网监听仍返回 `401` |
47
48
 
48
49
  - 移动网关默认关闭。手动开启后,默认 5 分钟内没有客户端成功建立连接就自动关闭。
49
50
  - 关闭移动网关会关闭现有连接,WebSocket close code 为 `4004`。
50
51
  - 从 Debug 模式重新开启鉴权时,所有无凭证连接会被关闭,close code 为 `4003`。
51
- - Debug 鉴权开关只在当前 DSH 进程中生效;重启后恢复配置中的 `requireAuth: true`。
52
+ - Debug 鉴权开关只影响 DSH 自带的本机监听,并且只在当前 DSH 进程中生效;独立局域网监听始终强制设备鉴权。
52
53
 
53
54
  #### 首次配对
54
55
 
@@ -150,7 +151,111 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
150
151
 
151
152
  ---
152
153
 
153
- ## 3. 消息(手机 → agent)
154
+ ## 3. Human-in-the-loop 提问与回答
155
+
156
+ Agent 调用 DSH 的 `ask_user_question` 工具时,插件通过 API Gateway 的 `events.mux()` 收到临时的待回答请求,并推送给移动端。该请求不属于持久化的 `session/event`;回答必须使用本节协议,不能作为普通 `message` 发送。
157
+
158
+ ### `question-requested` — 服务端推送问题
159
+
160
+ ```json
161
+ {
162
+ "kind": "question-requested",
163
+ "rpcId": "5ce4f5d1-...",
164
+ "sessionId": "session-abc",
165
+ "questions": [
166
+ {
167
+ "id": "research-direction",
168
+ "header": "研究方向",
169
+ "question": "你想深入研究哪个方向?",
170
+ "detail": "请选择最感兴趣的方向",
171
+ "options": [
172
+ { "label": "核心架构", "description": "DSH CLI、profile、bundle 与 Cordis" },
173
+ { "label": "移动网关", "description": "研究 iOS 与 WebSocket 插件" }
174
+ ],
175
+ "multiSelect": false
176
+ }
177
+ ]
178
+ }
179
+ ```
180
+
181
+ - `rpcId`:API Gateway 为这一整批问题生成的稳定 ID。回答或取消时必须原样返回,客户端不得自行生成。
182
+ - `questions`:一次工具调用中的完整问题批次;可能包含多题。
183
+ - `id`:问题 ID,必须在对应答案中原样返回。
184
+ - `header` / `detail`:可选展示信息。
185
+ - `options`:可选列表;每项包含 `label` 和可选 `description`。
186
+ - `multiSelect`:`true` 允许多选,缺省或 `false` 为单选。
187
+ - `intent`:可选展示意图。目前可能为 `{ "kind":"plan-review", "approve":"批准选项标签" }`;未知 intent 应退化为普通选项列表。
188
+ - `replay: true`:可选。表示这是移动端连接后重放的仍待回答问题。iOS 必须按 `rpcId` 去重。
189
+
190
+ ### `question-answer` — 移动端提交整批答案
191
+
192
+ ```json
193
+ {
194
+ "type": "question-answer",
195
+ "rpcId": "5ce4f5d1-...",
196
+ "sessionId": "session-abc",
197
+ "answers": [
198
+ {
199
+ "id": "research-direction",
200
+ "selected": ["移动网关"]
201
+ }
202
+ ]
203
+ }
204
+ ```
205
+
206
+ 自由输入使用 `custom`。单选题使用 `custom` 时 `selected` 必须为空;多选题可以同时携带两者:
207
+
208
+ ```json
209
+ {
210
+ "id": "research-direction",
211
+ "selected": [],
212
+ "custom": "我想研究 API Gateway 的安全边界"
213
+ }
214
+ ```
215
+
216
+ 提交规则由 API Gateway 严格校验:
217
+
218
+ - 必须一次提交这一批中的全部问题,`answers` 数量、顺序和 `id` 必须与 `questions` 一致。
219
+ - `selected` 中的值必须与原始 `options[].label` 完全一致,且不能重复。
220
+ - 单选题最多选择一项;单选题的 `custom` 与 `selected` 互斥。
221
+ - `custom` 如果存在,去除首尾空白后不能是空字符串。
222
+
223
+ 插件立即返回交付回执:
224
+
225
+ ```json
226
+ { "kind":"question-response", "rpcId":"5ce4f5d1-...", "sessionId":"session-abc",
227
+ "action":"answer", "accepted":true }
228
+ ```
229
+
230
+ 如果 WebUI 或另一台移动设备已经先回答:
231
+
232
+ ```json
233
+ { "kind":"question-response", "rpcId":"5ce4f5d1-...", "sessionId":"session-abc",
234
+ "action":"answer", "accepted":false, "reason":"not-pending" }
235
+ ```
236
+
237
+ 答案结构不合法时 `reason` 为 `bad-response`。这两种情况均不能重发为普通聊天消息。
238
+
239
+ ### `question-cancel` — 跳过/取消整批问题
240
+
241
+ ```json
242
+ { "type":"question-cancel", "rpcId":"5ce4f5d1-...", "sessionId":"session-abc" }
243
+ ```
244
+
245
+ 回执仍为 `question-response`,其中 `action` 为 `cancel`。取消会让等待中的 `ask_user_question` 以 `ASK_CANCELLED` 结束,iOS 应在用户确认后再执行。
246
+
247
+ ### `question-resolved` — 服务端广播最终状态
248
+
249
+ ```json
250
+ { "kind":"question-resolved", "rpcId":"5ce4f5d1-...", "sessionId":"session-abc",
251
+ "outcome":"answered" }
252
+ ```
253
+
254
+ `outcome` 为 `answered` 或 `cancelled`。WebUI、iOS 或其他客户端中的第一个合法响应获胜;所有移动连接都会收到最终状态并应关闭对应选择界面。移动端断线重连后,API Gateway 会重放仍待回答的问题;DSH 进程重启则会取消这些仅存在于运行时的问题。
255
+
256
+ ---
257
+
258
+ ## 4. 消息(手机 → agent)
154
259
 
155
260
  ### `message` — 发送消息(会话不存在则创建)
156
261
  ```json
@@ -168,7 +273,7 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
168
273
 
169
274
  ---
170
275
 
171
- ## 4. 会话与历史查询
276
+ ## 5. 会话与历史查询
172
277
 
173
278
  | type | 参数 | 说明 |
174
279
  |---|---|---|
@@ -207,7 +312,7 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
207
312
 
208
313
  ---
209
314
 
210
- ## 5. 工作区与目录
315
+ ## 6. 工作区与目录
211
316
 
212
317
  | type | 参数 | 说明 |
213
318
  |---|---|---|
@@ -223,7 +328,7 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
223
328
 
224
329
  ---
225
330
 
226
- ## 6. 模型与思考等级
331
+ ## 7. 模型与思考等级
227
332
 
228
333
  | type | 参数 | 说明 |
229
334
  |---|---|---|
@@ -255,7 +360,7 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
255
360
 
256
361
  ---
257
362
 
258
- ## 7. 权限控制
363
+ ## 8. 权限控制
259
364
 
260
365
  | type | 参数 | 说明 |
261
366
  |---|---|---|
@@ -270,7 +375,7 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
270
375
 
271
376
  ---
272
377
 
273
- ## 8. 新会话默认配置
378
+ ## 9. 新会话默认配置
274
379
 
275
380
  | type | 参数 | 说明 |
276
381
  |---|---|---|
@@ -288,7 +393,7 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
288
393
 
289
394
  ---
290
395
 
291
- ## 9. 分支(fork)
396
+ ## 10. 分支(fork)
292
397
 
293
398
  ```json
294
399
  { "type": "fork", "sessionId": "session-abc", "atSeq": 42 }
@@ -299,7 +404,7 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
299
404
 
300
405
  ---
301
406
 
302
- ## 10. 宿主信息
407
+ ## 11. 宿主信息
303
408
 
304
409
  | type | 返回 |
305
410
  |---|---|
@@ -313,13 +418,14 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
313
418
 
314
419
  ---
315
420
 
316
- ## 11. 服务端主动推送
421
+ ## 12. 服务端主动推送
317
422
 
318
423
  | kind | 触发时机 |
319
424
  |---|---|
320
425
  | `paired` | 首次配对成功;仅此一次返回长期设备 token |
321
426
  | `hello` | 连接成功:`{ "kind":"hello", "protocol":2, "authenticated":true, "port":3080, "clients":1 }` |
322
427
  | `event` | 任意会话的 agent 输出(见下) |
428
+ | `question-requested` / `question-resolved` | Human-in-the-loop 问题请求与最终状态 |
323
429
  | `pong` / `subscribed` / `sent` | 对应请求的回复 |
324
430
 
325
431
  ### `event` 帧(agent 实时输出)
@@ -337,7 +443,7 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
337
443
 
338
444
  ---
339
445
 
340
- ## 12. 端到端示例(Postman)
446
+ ## 13. 端到端示例(Postman)
341
447
 
342
448
  1. Connect → 收到 `hello`
343
449
  2. `{"type":"sessions"}` → 挑 `sessionId`(或直接下一步自动建)
@@ -350,7 +456,7 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
350
456
 
351
457
  ---
352
458
 
353
- ## 13. 安全注意
459
+ ## 14. 安全注意
354
460
 
355
461
  - `/ws/mobile` 的移动网关默认关闭;本机 WebUI 手动开启后,若 5 分钟内没有设备成功连接会自动关闭
356
462
  - 网关开启后仍要求已配对设备凭证;不要把 `requireAuth` 设为 `false` 后暴露到网络
@@ -358,10 +464,11 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
358
464
  - DSH HTTP Server 本身没有 TLS、认证或 Origin policy;公网必须使用 TLS 反向代理和 `wss://`
359
465
  - 长期 token 只保存在 iOS Keychain;服务端磁盘仅保存摘要
360
466
  - `set-default` / `save-default-model` 是全局写操作,客户端 UI 应加确认
467
+ - `question-answer` / `question-cancel` 会直接恢复或终止等待中的 Agent 工具调用;只允许经过鉴权的可信设备提交,并按 `rpcId` 防止重复操作
361
468
 
362
469
  ---
363
470
 
364
- ## 14. 版本历史(插件)
471
+ ## 15. 版本历史(插件)
365
472
 
366
473
  | 版本 | 新增 |
367
474
  |---|---|
@@ -379,6 +486,7 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
379
486
  | v0.1.16 | fork(新对话分支) |
380
487
  | v0.1.17 | models 支持无 sessionId 全局目录;新增 providers |
381
488
  | v0.3.0 | 默认设备鉴权;一次性二维码配对;摘要化凭证存储;WebUI 设备面板;在线状态和即时吊销 |
489
+ | v0.5.0 | Human-in-the-loop:转发 API Gateway question 请求、整批回答/取消、重连重放与多端状态收敛 |
382
490
 
383
491
  ---
384
492
 
package/README.md CHANGED
@@ -1,6 +1,10 @@
1
+ <p align="center">
2
+ <img src="docs/assets/whale-girl-ios-app-promo-16x9.png" alt="鲸鱼娘展示 DeepSeek Harness Mobile 与移动网关" width="100%">
3
+ </p>
4
+
1
5
  # dsh-plugin-mobile-gateway
2
6
 
3
- 为 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 提供经过设备鉴权的持久化 WebSocket 网关,让 iOS 等移动客户端能够查看工作区和历史会话、接收 Agent 实时输出、发送任务,以及调整会话模型与权限。
7
+ 为 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 提供经过设备鉴权的持久化 WebSocket 网关,让 iOS 等移动客户端能够查看工作区和历史会话、接收 Agent 实时输出、处理 Human-in-the-loop 选择、发送任务,以及调整会话模型与权限。
4
8
 
5
9
  ![DeepSeek Harness 移动设备管理面板](docs/assets/mobile-device-management.png)
6
10
 
@@ -24,7 +28,7 @@ dsh plugin --profile web add dsh-plugin-mobile-gateway
24
28
  如果希望固定版本,可以在包名后指定版本号:
25
29
 
26
30
  ```bash
27
- dsh plugin --profile web add dsh-plugin-mobile-gateway@0.4.1
31
+ dsh plugin --profile web add dsh-plugin-mobile-gateway@0.5.0
28
32
  ```
29
33
 
30
34
  也可以不经过 npm,直接安装 GitHub 版本:
@@ -45,7 +49,19 @@ dsh --profile web --dump-config | grep -A3 mobile-gateway
45
49
 
46
50
  启动 WebUI 后,左侧边栏底部应出现“移动设备”入口。
47
51
 
48
- ### 3. 一条命令配置公网 IP(推荐,无需域名)
52
+ ### 3. 局域网直接连接(无需域名、证书或反向代理)
53
+
54
+ 插件安装并重启 `dsh web` 后,会额外创建一个仅用于移动端的局域网监听:
55
+
56
+ ```text
57
+ ws://<运行 DSH 的电脑局域网 IP>:3081/ws/mobile
58
+ ```
59
+
60
+ 打开 WebUI 的“移动设备”面板时,插件会自动检测电脑的私有 IPv4,并优先把可用地址填入“WebSocket 地址”。开启移动网关、生成二维码并用 iOS 客户端扫码即可。电脑和手机需要位于可互访的同一局域网;如果系统防火墙询问是否允许 Node 接收入站连接,请允许私有网络访问 TCP `3081`。
61
+
62
+ 该端口只接受来自本机或私有网络地址的 `/ws/mobile` WebSocket Upgrade,不提供 WebUI,也不暴露 `/mgw/*` 管理接口。局域网监听始终要求设备配对鉴权,WebUI 中的 Debug 鉴权开关不会关闭它的鉴权。
63
+
64
+ ### 4. 一条命令配置公网 IP(推荐,无需域名)
49
65
 
50
66
  如果 Harness 部署在带固定公网 IPv4 的腾讯云 Ubuntu/Debian 服务器上,先在腾讯云安全组中放行入站 TCP `80` 和 `443`,然后在服务器执行:
51
67
 
@@ -90,7 +106,7 @@ sudo npx --yes dsh-plugin-mobile-gateway remove
90
106
 
91
107
  1. 开启“允许移动设备连接”。
92
108
  2. 保持“设备鉴权”开启。
93
- 3. 在“公网 WebSocket 地址”中填写手机可以访问的地址。
109
+ 3. 在“WebSocket 地址”中填写手机可以访问的地址。
94
110
 
95
111
  本机浏览器调试可以使用:
96
112
 
@@ -98,15 +114,15 @@ sudo npx --yes dsh-plugin-mobile-gateway remove
98
114
  ws://127.0.0.1:3080/ws/mobile
99
115
  ```
100
116
 
101
- 真机不能使用 `127.0.0.1`,因为它在手机上指向手机自身。局域网或公网真机连接应通过 TLS 反向代理提供:
117
+ 真机不能使用 `127.0.0.1`,因为它在手机上指向手机自身。同一私有局域网可使用插件自动提供的 `ws://局域网IP:3081/ws/mobile`;公网连接则应通过 TLS 反向代理提供:
102
118
 
103
119
  ```text
104
120
  wss://gateway.example.com/ws/mobile
105
121
  ```
106
122
 
107
- localhost 的配对地址会强制要求 `wss://`,以免一次性配对信息和长期连接暴露在明文网络中。
123
+ 只有 localhost、`.local` 主机名及 RFC 1918/链路本地私有地址允许使用 `ws://`。其他地址会强制要求 `wss://`,以免一次性配对信息和长期连接暴露在公网明文网络中。
108
124
 
109
- ### 4. 配对 iOS 客户端
125
+ ### 5. 配对 iOS 客户端
110
126
 
111
127
  1. 在 WebUI 中填写设备名称,例如 `iPhone`。
112
128
  2. 点击“生成配对二维码”。
@@ -196,6 +212,10 @@ TLS 证书、域名、防火墙、访问日志保护和代理层速率限制由
196
212
  gatewayWaitTimeoutMs: 300000
197
213
  requireAuth: true
198
214
  adminLoopbackOnly: true
215
+ lanEnabled: true
216
+ lanHost: 0.0.0.0
217
+ lanPort: 3081
218
+ lanAdvertiseHost: ''
199
219
  publicUrl: wss://gateway.example.com/ws/mobile
200
220
  publicUrlFile: /etc/dsh-mobile-gateway/public-url
201
221
  pairingTtlMs: 300000
@@ -211,7 +231,8 @@ TLS 证书、域名、防火墙、访问日志保护和代理层速率限制由
211
231
  | WebUI 没有“移动设备”入口 | 确认使用 `--profile web` 安装;执行 `--dump-config` 检查组合树,然后完整重启 `dsh web` |
212
232
  | iOS 连接提示 `503` | 移动网关尚未开启,回到 WebUI 开启“允许移动设备连接” |
213
233
  | iOS 连接提示 `401` | 配对码过期、长期凭证无效或设备已被吊销;删除客户端旧凭证后重新配对 |
214
- | 真机无法连接 `127.0.0.1` | `127.0.0.1` 在手机上不是电脑;配置手机可访问的 `wss://` 地址 |
234
+ | 真机无法连接 `127.0.0.1` | `127.0.0.1` 在手机上不是电脑;局域网使用面板自动显示的 `ws://私有IP:3081/ws/mobile`,公网使用 `wss://` 地址 |
235
+ | 同一 Wi-Fi 仍无法连接局域网地址 | 确认手机与电脑所在网络允许设备互访,并放行电脑入站 TCP 3081;访客 Wi-Fi 通常会启用客户端隔离 |
215
236
  | 一键配置无法识别公网 IP | 确认命令运行在腾讯云 CVM 内,或通过 `--ip <公网 IPv4>` 显式指定 |
216
237
  | Certbot 申请或续期失败 | 确认腾讯云安全组和服务器防火墙都允许入站 TCP 80/443,并确认公网 IP 没有变化 |
217
238
  | 想检查自动续期 | 执行 `sudo systemctl status dsh-mobile-gateway-cert-renew.timer` 和 `sudo npx --yes dsh-plugin-mobile-gateway status` |
@@ -247,6 +268,9 @@ NODE_PATH=/path/to/dsh/node_modules node test/gateway.test.mjs
247
268
 
248
269
  # 公网 IP 安装器的参数、Nginx 隔离与续期配置
249
270
  node test/setup-ip.test.mjs
271
+
272
+ # 独立局域网监听、强制鉴权与管理接口隔离
273
+ node test/lan.test.mjs
250
274
  ```
251
275
 
252
276
  版本历史与全部 WebSocket 消息类型见 [`PROTOCOL.md`](PROTOCOL.md)。
package/cordis.patch.yml CHANGED
@@ -13,6 +13,12 @@
13
13
  requireAuth: true
14
14
  adminLoopbackOnly: true
15
15
  pairingTtlMs: 300000
16
+ # DSH WebUI remains loopback-only on 3080. The plugin owns this
17
+ # authenticated, WebSocket-only LAN listener for phones on the same
18
+ # private network.
19
+ lanEnabled: true
20
+ lanHost: 0.0.0.0
21
+ lanPort: 3081
16
22
  # Written automatically by `sudo npx dsh-plugin-mobile-gateway setup`.
17
23
  publicUrlFile: /etc/dsh-mobile-gateway/public-url
18
24