dsh-plugin-mobile-gateway 0.4.2 → 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,6 +1,6 @@
1
1
  # dsh Mobile Gateway — WebSocket 协议参考
2
2
 
3
- 移动端通过一个经过设备鉴权的 WebSocket 连接与 dsh 通信:订阅 agent 实时输出、发送消息、查询会话/工作区/历史、调整会话配置。本协议由持久化插件 `dsh-plugin-mobile-gateway` 实现(v0.4.2)。
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
6
  - **局域网端点**:`ws://<电脑的私有局域网 IP>:3081/ws/mobile`(插件独立监听,只提供经过鉴权的 WebSocket)
@@ -151,7 +151,111 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
151
151
 
152
152
  ---
153
153
 
154
- ## 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)
155
259
 
156
260
  ### `message` — 发送消息(会话不存在则创建)
157
261
  ```json
@@ -169,7 +273,7 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
169
273
 
170
274
  ---
171
275
 
172
- ## 4. 会话与历史查询
276
+ ## 5. 会话与历史查询
173
277
 
174
278
  | type | 参数 | 说明 |
175
279
  |---|---|---|
@@ -208,7 +312,7 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
208
312
 
209
313
  ---
210
314
 
211
- ## 5. 工作区与目录
315
+ ## 6. 工作区与目录
212
316
 
213
317
  | type | 参数 | 说明 |
214
318
  |---|---|---|
@@ -224,7 +328,7 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
224
328
 
225
329
  ---
226
330
 
227
- ## 6. 模型与思考等级
331
+ ## 7. 模型与思考等级
228
332
 
229
333
  | type | 参数 | 说明 |
230
334
  |---|---|---|
@@ -256,7 +360,7 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
256
360
 
257
361
  ---
258
362
 
259
- ## 7. 权限控制
363
+ ## 8. 权限控制
260
364
 
261
365
  | type | 参数 | 说明 |
262
366
  |---|---|---|
@@ -271,7 +375,7 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
271
375
 
272
376
  ---
273
377
 
274
- ## 8. 新会话默认配置
378
+ ## 9. 新会话默认配置
275
379
 
276
380
  | type | 参数 | 说明 |
277
381
  |---|---|---|
@@ -289,7 +393,7 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
289
393
 
290
394
  ---
291
395
 
292
- ## 9. 分支(fork)
396
+ ## 10. 分支(fork)
293
397
 
294
398
  ```json
295
399
  { "type": "fork", "sessionId": "session-abc", "atSeq": 42 }
@@ -300,7 +404,7 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
300
404
 
301
405
  ---
302
406
 
303
- ## 10. 宿主信息
407
+ ## 11. 宿主信息
304
408
 
305
409
  | type | 返回 |
306
410
  |---|---|
@@ -314,13 +418,14 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
314
418
 
315
419
  ---
316
420
 
317
- ## 11. 服务端主动推送
421
+ ## 12. 服务端主动推送
318
422
 
319
423
  | kind | 触发时机 |
320
424
  |---|---|
321
425
  | `paired` | 首次配对成功;仅此一次返回长期设备 token |
322
426
  | `hello` | 连接成功:`{ "kind":"hello", "protocol":2, "authenticated":true, "port":3080, "clients":1 }` |
323
427
  | `event` | 任意会话的 agent 输出(见下) |
428
+ | `question-requested` / `question-resolved` | Human-in-the-loop 问题请求与最终状态 |
324
429
  | `pong` / `subscribed` / `sent` | 对应请求的回复 |
325
430
 
326
431
  ### `event` 帧(agent 实时输出)
@@ -338,7 +443,7 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
338
443
 
339
444
  ---
340
445
 
341
- ## 12. 端到端示例(Postman)
446
+ ## 13. 端到端示例(Postman)
342
447
 
343
448
  1. Connect → 收到 `hello`
344
449
  2. `{"type":"sessions"}` → 挑 `sessionId`(或直接下一步自动建)
@@ -351,7 +456,7 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
351
456
 
352
457
  ---
353
458
 
354
- ## 13. 安全注意
459
+ ## 14. 安全注意
355
460
 
356
461
  - `/ws/mobile` 的移动网关默认关闭;本机 WebUI 手动开启后,若 5 分钟内没有设备成功连接会自动关闭
357
462
  - 网关开启后仍要求已配对设备凭证;不要把 `requireAuth` 设为 `false` 后暴露到网络
@@ -359,10 +464,11 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
359
464
  - DSH HTTP Server 本身没有 TLS、认证或 Origin policy;公网必须使用 TLS 反向代理和 `wss://`
360
465
  - 长期 token 只保存在 iOS Keychain;服务端磁盘仅保存摘要
361
466
  - `set-default` / `save-default-model` 是全局写操作,客户端 UI 应加确认
467
+ - `question-answer` / `question-cancel` 会直接恢复或终止等待中的 Agent 工具调用;只允许经过鉴权的可信设备提交,并按 `rpcId` 防止重复操作
362
468
 
363
469
  ---
364
470
 
365
- ## 14. 版本历史(插件)
471
+ ## 15. 版本历史(插件)
366
472
 
367
473
  | 版本 | 新增 |
368
474
  |---|---|
@@ -380,6 +486,7 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
380
486
  | v0.1.16 | fork(新对话分支) |
381
487
  | v0.1.17 | models 支持无 sessionId 全局目录;新增 providers |
382
488
  | v0.3.0 | 默认设备鉴权;一次性二维码配对;摘要化凭证存储;WebUI 设备面板;在线状态和即时吊销 |
489
+ | v0.5.0 | Human-in-the-loop:转发 API Gateway question 请求、整批回答/取消、重连重放与多端状态收敛 |
383
490
 
384
491
  ---
385
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.2
31
+ dsh plugin --profile web add dsh-plugin-mobile-gateway@0.5.0
28
32
  ```
29
33
 
30
34
  也可以不经过 npm,直接安装 GitHub 版本:
@@ -0,0 +1,542 @@
1
+ # 让手机成为 AI Agent 的第二块屏幕:dsh-plugin-mobile-gateway
2
+
3
+ > 一个给 DeepSeek Harness 写的常驻插件:在不改动宿主一行代码的前提下,为移动端加上一条经过设备鉴权的实时通道。
4
+
5
+ ---
6
+
7
+ ## 一、它解决什么问题
8
+
9
+ 用过 CLI 形态 Coding Agent 的人大概都有同一个体验:给 Agent 派一个稍复杂的任务,它开始读文件、跑测试、改代码,一轮下来三五分钟。这三五分钟里你被钉在电脑前——不是因为你要做什么,而是因为你**不知道它做到哪了**。
10
+
11
+ `dsh-plugin-mobile-gateway` 要解决的就是这件事:
12
+
13
+ > **把手机变成远端 Agent 的第二块屏幕**——实时看到思考、工具调用和回答,随时下发新任务。
14
+
15
+ ![移动设备管理面板](assets/mobile-device-management.png)
16
+
17
+ 它的定位很轻:**不重新实现任何 Agent 逻辑**,只是把 DSH 浏览器 UI 用的那套官方 Host API 和 `session/event` 事件流,转译成一套精简的 JSON WebSocket 协议转发给手机。
18
+
19
+ ---
20
+
21
+ ## 二、当前支持的网络范围
22
+
23
+ 这是需要先说清楚的边界:
24
+
25
+ | 场景 | 支持情况 |
26
+ |---|---|
27
+ | 家庭局域网(同一路由器下) | ✅ 已支持,零配置 |
28
+ | 办公局域网 | ✅ 已支持,零配置 |
29
+ | 可信 VPN 网络(WireGuard / 企业 VPN 等) | ✅ 已支持 |
30
+ | 本机浏览器调试 | ✅ 已支持 |
31
+ | **公网直连** | 🚧 **开发中** |
32
+
33
+ 也就是说,**当前版本的定位是"可信私有网络内的移动端遥控"**。手机和电脑需要处在同一个可互访的私有网络里——同一个 Wi-Fi,或者通过 VPN 接入同一段内网。
34
+
35
+ > VPN 场景的前提:VPN 需要给客户端分配 RFC 1918 私有地址(如 `10.x.x.x`、`192.168.x.x`、`172.16-31.x.x`),这样才能通过插件的来源校验。
36
+
37
+ 公网连接能力正在开发中。这不是技术难度问题,而是**安全边界问题**:一旦端点暴露到公网,就必须同时解决 TLS 终止、证书生命周期、速率限制、日志脱敏、暴力破解防护等一整套问题。在这些没有稳妥收口之前,插件选择把网络面**严格限制在私有网段**,宁可少一个场景,不留一个开口。
38
+
39
+ ---
40
+
41
+ ## 三、连接原理
42
+
43
+ ### 3.1 两个监听器,各管一段
44
+
45
+ DSH 自己坚持只监听 `127.0.0.1`,这是它的安全立场。插件没有去改宿主、也没有劝它放开,而是**自己开了第二个窄口径 HTTP server**:
46
+
47
+ | 监听器 | 地址 | 提供什么 |
48
+ |---|---|---|
49
+ | DSH 自带(宿主) | `127.0.0.1:3080` | WebUI + `/mgw` 管理接口 + `/ws/mobile` |
50
+ | **插件自建(LAN)** | `0.0.0.0:3081` | **只有** `/ws/mobile`,别的全部 404 |
51
+
52
+ 插件自建的这个监听器做了三层收窄:
53
+
54
+ ```js
55
+ lanServer = http.createServer((req, res) => {
56
+ sendJson(res, 404, { error: 'not-found' }) // ① 普通 HTTP 请求一律 404
57
+ })
58
+
59
+ lanServer.on('upgrade', (req, socket, head) => {
60
+ if (pathname !== wsPath) {
61
+ rejectUpgrade(socket, 404, 'not found') // ② 只认 /ws/mobile 这一条路径
62
+ return
63
+ }
64
+ if (!isPrivateNetworkHostname(req.socket.remoteAddress)) {
65
+ rejectUpgrade(socket, 403, // ③ 只接受私有网段来源
66
+ 'LAN listener accepts private-network clients only')
67
+ return
68
+ }
69
+ handleMobileUpgrade(req, socket, head, { lan: true, port: lanBoundPort })
70
+ })
71
+ ```
72
+
73
+ 它**不提供 WebUI、不提供管理接口**。暴露到局域网的,只是一个"只能做鉴权 WebSocket 连接"的端点。
74
+
75
+ 更关键的一点:WebUI 上那个 Debug 用的"关闭设备鉴权"开关**管不到它**——
76
+
77
+ ```js
78
+ // LAN 入口无条件强制鉴权,Debug 开关只影响 loopback
79
+ if ((requireAuth || transport.lan === true) && !device) {
80
+ logAuthRejected(req)
81
+ rejectUpgrade(socket, 401, 'missing or invalid device credential')
82
+ return
83
+ }
84
+ ```
85
+
86
+ 一个"方便调试"的开关,永远不可能把暴露在局域网上的端点变成开放控制面。
87
+
88
+ ### 3.2 私有网段判定
89
+
90
+ 来源校验和地址合法性校验都走同一个函数,覆盖 IPv4 私有段、链路本地、`.local` 主机名,以及 IPv6 的 `fc00::/7` 和 `fe80::/10`:
91
+
92
+ ```js
93
+ function isPrivateNetworkHostname(hostname) {
94
+ let normalized = hostname.replace(/^\[|\]$/g, '').split('%')[0].toLowerCase()
95
+ if (normalized.startsWith('::ffff:')) normalized = normalized.slice('::ffff:'.length)
96
+ if (isLocalHostname(normalized) || normalized.endsWith('.local')) return true
97
+
98
+ const octets = normalized.split('.').map(Number)
99
+ if (octets.length === 4 && octets.every((p) => Number.isInteger(p) && p >= 0 && p <= 255)) {
100
+ return octets[0] === 10 // 10.0.0.0/8
101
+ || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) // 172.16.0.0/12
102
+ || (octets[0] === 192 && octets[1] === 168) // 192.168.0.0/16
103
+ || (octets[0] === 169 && octets[1] === 254) // 链路本地
104
+ }
105
+ return /^(?:f[cd][0-9a-f]{2}:|fe[89ab][0-9a-f]:)/i.test(normalized)
106
+ }
107
+ ```
108
+
109
+ 这个函数同时也是"什么地址允许用明文 `ws://`"的唯一判据。只有 localhost、`.local` 和上面这些私有段可以用 `ws://`,其他地址一律要求 `wss://`:
110
+
111
+ ```js
112
+ if (url.protocol === 'ws:' && !isPrivateNetworkHostname(url.hostname))
113
+ throw badRequest('publicUrl must use wss:// outside localhost or a private LAN')
114
+ ```
115
+
116
+ ### 3.3 地址自动探测
117
+
118
+ 用户不需要自己查 IP。面板打开时插件会枚举网卡,并且**把虚拟网卡降级为 fallback**,优先展示物理网卡地址:
119
+
120
+ ```js
121
+ function privateLanAddresses() {
122
+ const physical = []
123
+ const fallback = []
124
+ const virtualInterface = /^(?:docker|br-|veth|utun|awdl|llw|vmnet|vbox|virbr|tailscale|wg)/i
125
+ for (const [name, records] of Object.entries(os.networkInterfaces())) {
126
+ for (const record of records || []) {
127
+ if (!record || record.internal || record.family !== 'IPv4'
128
+ || !isPrivateNetworkHostname(record.address)) continue
129
+ const target = virtualInterface.test(name) ? fallback : physical
130
+ if (!target.includes(record.address)) target.push(record.address)
131
+ }
132
+ }
133
+ return physical.length ? physical : fallback
134
+ }
135
+ ```
136
+
137
+ 有了这段,面板里显示的就是 `192.168.1.23` 而不是某个 Docker 桥地址。**这个细节决定了"零配置"到底成不成立。**
138
+
139
+ 面板最终填入的地址优先级是:配置的公网地址 → 局域网探测地址 → 从当前页面推断。用户手动改过之后,轮询不会再覆盖。
140
+
141
+ ### 3.4 双向数据流
142
+
143
+ **下行(Agent → 手机)**:监听宿主的 `session/event`,和浏览器 UI 消费的是同一条 feed:
144
+
145
+ ```js
146
+ const disposeEvents = ctx.on('session/event', (session, event) => {
147
+ if (clients.size === 0) return // 无客户端时零开销
148
+ const wire = buildWireEvent(session, event) // 压成小 JSON
149
+ if (!wire) return
150
+ const payload = JSON.stringify(wire)
151
+ for (const client of clients) {
152
+ if (client.filterSessionId && client.filterSessionId !== String(session.id)) continue
153
+ if (client.readyState === 1) client.send(payload)
154
+ }
155
+ })
156
+ ```
157
+
158
+ `buildWireEvent` 遵守一条纪律(源码注释原文):
159
+
160
+ > Reads only leaf fields of the live SessionEvent — never serializes live objects.
161
+
162
+ **只读叶子字段,绝不序列化活对象。** 既避免把宿主内部结构泄漏到网线上,也避免循环引用和意外的巨型 payload。
163
+
164
+ **上行(手机 → Agent)**:走官方 API,不开旁路:
165
+
166
+ ```js
167
+ const resp = await api.sessions.prompt({
168
+ rpcId: crypto.randomUUID(),
169
+ payload: { sessionId, mode, content: [{ type: 'text', text }] },
170
+ })
171
+ ```
172
+
173
+ 手机发的消息和浏览器提交的 prompt **走完全相同的路径**。`mode` 支持 `queue`(排队)和 `steer`(打断当前回合)。
174
+
175
+ ---
176
+
177
+ ## 四、配对原理
178
+
179
+ 这是整个插件的安全核心。设计目标是:**长期凭证只在网线上出现一次,服务端磁盘上永远没有明文。**
180
+
181
+ ### 4.1 完整流程
182
+
183
+ ```
184
+ [WebUI] 点击"生成配对二维码"
185
+
186
+ ├─ createPairing()
187
+ │ 生成 256-bit 一次性配对码
188
+ │ 内存 Map 只存 SHA-256(code),明文不落盘、不持久化
189
+ │ 5 分钟后过期
190
+
191
+ ├─ payload = { version:2, publicUrl, pairingCode, expiresAt }
192
+ ├─ Base64URL(无 padding)编码
193
+ └─ 渲染成 QR SVG,同时提供文本供手动粘贴
194
+
195
+ [iOS] 扫码 / 粘贴后建立连接
196
+ │ Sec-WebSocket-Protocol: dsh-mobile-v1, dsh-pair.<code>
197
+ │ X-DSH-Device-ID: <Keychain 中的安装级 UUID>
198
+
199
+ ├─ claimPairing():先 delete 再干活 → 严格单次使用
200
+ ├─ 签发 256-bit 长期 token
201
+ ├─ 落盘只存 tokenHash(SHA-256)
202
+ └─ 下发 { kind:'paired', token, device } ← 仅此一次,不会再下发
203
+
204
+ [iOS] token 写入 Keychain
205
+
206
+ └─ 后续所有连接:
207
+ Authorization: Bearer <token> (推荐)
208
+ 或 Sec-WebSocket-Protocol: dsh-mobile-v1, dsh-auth.<token>
209
+ ```
210
+
211
+ ### 4.2 核心代码:签发与单次使用
212
+
213
+ ```js
214
+ claimPairing(code, clientDeviceId) {
215
+ prunePairings()
216
+ if (typeof code !== 'string' || code === '') return undefined
217
+ const codeHash = digest(code)
218
+ const pairing = pairings.get(codeHash)
219
+ if (!pairing) return undefined
220
+
221
+ // 先删除再做后续工作:即使之后 socket upgrade 失败,这个码也已作废
222
+ pairings.delete(codeHash)
223
+
224
+ const token = crypto.randomBytes(32).toString('base64url')
225
+ const normalizedClientDeviceId = normalizeClientDeviceId(clientDeviceId)
226
+
227
+ // 同一台 iOS 重新配对时复用已有设备记录,只轮换凭证,不产生重复行
228
+ let device = normalizedClientDeviceId
229
+ ? devices.find((c) => !c.revokedAt && c.clientDeviceId === normalizedClientDeviceId)
230
+ : undefined
231
+ if (device) {
232
+ device.name = pairing.name
233
+ device.tokenHash = digest(token) // 只存摘要
234
+ device.clientDeviceId = normalizedClientDeviceId
235
+ } else {
236
+ device = {
237
+ id: pairing.id,
238
+ name: pairing.name,
239
+ clientDeviceId: normalizedClientDeviceId,
240
+ tokenHash: digest(token), // 只存摘要
241
+ createdAt: Date.now(),
242
+ lastSeenAt: null,
243
+ revokedAt: null,
244
+ }
245
+ devices.push(device)
246
+ }
247
+ save()
248
+ return { device: publicDevice(device, 0), token } // token 只在此刻返回
249
+ }
250
+ ```
251
+
252
+ 注意 `pairings.delete(codeHash)` 的位置——**在做任何其他工作之前**。这样即使后续 WebSocket 握手失败,这个配对码也已经作废了,不存在"失败可重试所以能被反复尝试"的窗口。
253
+
254
+ ### 4.3 核心代码:凭证校验
255
+
256
+ ```js
257
+ function digest(secret) {
258
+ return crypto.createHash('sha256').update(secret, 'utf8').digest('hex')
259
+ }
260
+
261
+ // 定长 + timing-safe 比较,不泄漏比较进度
262
+ function safeEqualHex(left, right) {
263
+ if (typeof left !== 'string' || typeof right !== 'string') return false
264
+ const a = Buffer.from(left, 'hex')
265
+ const b = Buffer.from(right, 'hex')
266
+ return a.length === 32 && b.length === 32 && crypto.timingSafeEqual(a, b)
267
+ }
268
+
269
+ authenticate(token, clientDeviceId) {
270
+ if (typeof token !== 'string' || token === '') return undefined
271
+ const tokenHash = digest(token)
272
+ const device = devices.find(
273
+ (c) => !c.revokedAt && safeEqualHex(c.tokenHash, tokenHash))
274
+ // ... 老设备补绑 clientDeviceId 的迁移逻辑
275
+ return device ? publicDevice(device, online.get(device.id) || 0) : undefined
276
+ }
277
+ ```
278
+
279
+ ### 4.4 凭证提取:为什么 token 不走 URL
280
+
281
+ ```js
282
+ function extractCredential(req, allowQueryToken) {
283
+ // ① 首选 Authorization 头,且严格校验长度(32 字节 base64url = 43 字符)
284
+ const authorization = req.headers.authorization
285
+ if (typeof authorization === 'string') {
286
+ const match = /^Bearer\s+([A-Za-z0-9_-]{43})$/i.exec(authorization.trim())
287
+ if (match) return { kind: 'token', value: match[1] }
288
+ }
289
+
290
+ // ② 次选 WebSocket 子协议(不会进入常见的 URL access log)
291
+ for (const protocol of parseProtocols(req)) {
292
+ if (protocol.startsWith('dsh-auth.')) return { kind: 'token', value: protocol.slice(9) }
293
+ if (protocol.startsWith('dsh-pair.')) return { kind: 'pairing', value: protocol.slice(9) }
294
+ }
295
+
296
+ // ③ 一次性配对码允许走 query(兼容性);长期 token 默认禁止
297
+ const query = new URL(req.url || '/', 'http://localhost').searchParams
298
+ const pairing = query.get('pairingCode')
299
+ if (pairing) return { kind: 'pairing', value: pairing }
300
+ const token = allowQueryToken && query.get('token')
301
+ if (token) return { kind: 'token', value: token }
302
+ }
303
+ ```
304
+
305
+ 长期 token 默认禁止放在 URL query(`allowQueryToken: false`),因为 query 会进入反向代理 access log、浏览器历史、APM 监控系统。而一次性配对码允许走 query——它单次使用 + 5 分钟过期,威胁窗口极小。
306
+
307
+ 还有一处容易被忽略的防护:**密钥绝不回显为协商结果**。鉴权信息可以搭在子协议里传,但服务端握手时只回固定值:
308
+
309
+ ```js
310
+ handleProtocols(protocols) {
311
+ return protocols.has('dsh-mobile-v1') ? 'dsh-mobile-v1' : false
312
+ }
313
+ ```
314
+
315
+ ### 4.5 `X-DSH-Device-ID` 不是凭证
316
+
317
+ 这点文档里反复强调过。它是客户端在 Keychain 里持久保存的安装级随机 UUID,唯一作用是**重新配对时复用同一条可信设备记录**,避免每次重连都刷出一堆重复设备行。它不能替代配对码或 token 完成鉴权:
318
+
319
+ ```js
320
+ function extractClientDeviceId(req) {
321
+ const value = req.headers['x-dsh-device-id']
322
+ if (typeof value !== 'string') return undefined
323
+ const normalized = value.trim()
324
+ return /^[A-Za-z0-9._:-]{8,128}$/.test(normalized) ? normalized : undefined
325
+ }
326
+ ```
327
+
328
+ 配对时**必须**携带它,缺失直接 400 拒绝(提示升级客户端)。
329
+
330
+ ### 4.6 关于 Base64URL 的诚实标注
331
+
332
+ 配对载荷用 Base64URL 编码,源码注释写得很直白:
333
+
334
+ > Base64URL is copy-safe and QR-safe (`+`, `/`, and `=` never appear), but is **encoding rather than encryption**; secrecy still comes from the short TTL and single-use pairing code.
335
+
336
+ 没有把 Base64 包装成"加密",而是说清楚"保密性来自短 TTL 和单次使用"。这种诚实的注释比任何安全声明都可靠——它让后来的维护者知道**安全边界到底在哪**。
337
+
338
+ ### 4.7 其余配对相关的安全措施
339
+
340
+ - **默认关闭 + 自愈**:网关默认 `false`;手动开启后 5 分钟内无设备连上会自动关闭("忘记关掉"是最常见的人为漏洞)
341
+ - **文件权限**:设备文件 `0600`、目录 `0700`、临时文件 + `rename` 原子写入
342
+ - **格式迁移**:旧开发版的明文 token 会被自动 hash,下次保存时清除明文
343
+ - **管理面隔离**:`/mgw/*` 默认只接受 loopback;写操作额外要求同源(防 CSRF);请求体上限 16 KiB
344
+ - **即时吊销**:删记录 + close code `4003` 踢掉现有连接,token 永久失效
345
+ - **日志抑制**:失败尝试按 30 秒窗口聚合,避免日志被刷爆,同时保留 remote / origin / user-agent 便于溯源
346
+
347
+ 约定的 close code,客户端可以据此写状态机:
348
+
349
+ | 信号 | 含义 | 客户端应做 |
350
+ |---|---|---|
351
+ | `4003` | 鉴权被重新开启 / 设备被吊销 | 用 token 重连,失败则重新配对 |
352
+ | `4004` | 移动网关已关闭 | **停止自动重连** |
353
+ | HTTP `503` | 网关未开启 | 停止高频重连,等用户开启 |
354
+ | HTTP `401` | 凭证缺失 / 无效 / 被吊销 | 清除 Keychain,进入重新配对 |
355
+
356
+ ---
357
+
358
+ ## 五、插件是怎么挂上去的
359
+
360
+ DSH 用 Cordis 的依赖注入容器编排"服务行"。插件通过 `package.json` 的 `dsh.bundle.patch` 声明一个组合树补丁:
361
+
362
+ ```yaml
363
+ # cordis.patch.yml
364
+ - insert:
365
+ - id: mobile-gateway
366
+ name: 'dsh-plugin-mobile-gateway'
367
+ config:
368
+ gatewayEnabled: false # 默认关闭
369
+ requireAuth: true # 默认强制鉴权
370
+ adminLoopbackOnly: true # 管理面仅本机
371
+ lanEnabled: true
372
+ lanHost: 0.0.0.0
373
+ lanPort: 3081
374
+ ```
375
+
376
+ 同时声明硬依赖,Cordis 保证服务就绪后才激活它:
377
+
378
+ ```js
379
+ const plugin = {
380
+ name: 'mobile-gateway',
381
+ Config,
382
+ inject: ['webServer', 'apiProxy', 'typertGateway', 'agentDefaultModel'],
383
+ apply(ctx, config) { /* ... */ },
384
+ }
385
+ ```
386
+
387
+ 配置 schema 里的默认值全部站在安全一侧,注释写明了理由:
388
+
389
+ ```js
390
+ // Secure by default: installing the bundle must never create an
391
+ // unauthenticated network control plane.
392
+ const Config = Schema.object({
393
+ path: Schema.string().default('/ws/mobile'),
394
+ requireAuth: Schema.boolean().default(true),
395
+ gatewayEnabled: Schema.boolean().default(false),
396
+ gatewayWaitTimeoutMs: Schema.natural().min(30_000).max(30 * 60 * 1000).default(300_000),
397
+ adminLoopbackOnly: Schema.boolean().default(true),
398
+ pairingTtlMs: Schema.natural().min(30_000).max(15 * 60 * 1000).default(300_000),
399
+ allowQueryToken: Schema.boolean().default(false),
400
+ lanEnabled: Schema.boolean().default(false),
401
+ lanHost: Schema.string().default('0.0.0.0'),
402
+ lanPort: Schema.natural().min(1).max(65535).default(3081),
403
+ })
404
+ ```
405
+
406
+ 浏览器侧的管理面板(`lib/client.js`)用 `React.createElement` 写成,**没有 JSX、没有打包步骤**,只往两个 slot 插东西,不替换任何官方 UI 座位:
407
+
408
+ ```js
409
+ function apply(ctx) {
410
+ ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register(
411
+ { name: 'sidebar.footer.action', id: 'mobile-gateway-devices', order: 80, label: '移动设备' },
412
+ (props) => React.createElement(FooterButton, props),
413
+ ))
414
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register(
415
+ { name: 'shell.overlay', id: 'mobile-gateway-devices-panel', order: 100, label: '移动设备' },
416
+ () => React.createElement(OverlayEntry),
417
+ ))
418
+ }
419
+ ```
420
+
421
+ ---
422
+
423
+ ## 六、安装方式
424
+
425
+ ### 1. 安装插件(无需下载源码)
426
+
427
+ 确保本机已能正常运行 DSH,然后:
428
+
429
+ ```bash
430
+ dsh plugin --profile web add dsh-plugin-mobile-gateway
431
+ ```
432
+
433
+ 这条命令会从 npm 获取插件、安装依赖,并把它加入 `web` profile 的 bundle 列表。**不需要 clone 仓库,不需要 `pnpm install`。**
434
+
435
+ 固定版本或直接装 GitHub 版:
436
+
437
+ ```bash
438
+ dsh plugin --profile web add dsh-plugin-mobile-gateway@0.4.2
439
+ dsh plugin --profile web add github:Clarklevis1995/dsh-plugin-mobile-gateway
440
+ ```
441
+
442
+ ### 2. 重启 WebUI(最容易踩的坑)
443
+
444
+ 插件组合树**只在 WebUI 启动时加载**。刷新浏览器无效,必须停掉 `dsh web` 再重新启动。
445
+
446
+ 启动前可以先验证插件是否进了组合配置:
447
+
448
+ ```bash
449
+ dsh --profile web --dump-config | grep -A3 mobile-gateway
450
+ ```
451
+
452
+ 启动后左侧边栏底部出现"移动设备"入口,即安装成功。
453
+
454
+ ### 3. 局域网连接(零配置)
455
+
456
+ 重启后插件自动开一个 `3081` 监听。打开"移动设备"面板,"WebSocket 地址"会自动填好:
457
+
458
+ ```text
459
+ ws://<运行 DSH 的电脑私有 IP>:3081/ws/mobile
460
+ ```
461
+
462
+ 需要注意:
463
+
464
+ - 手机和电脑必须在**可互访**的同一私有网络(访客 Wi-Fi 通常开了客户端隔离,不行)
465
+ - 系统防火墙询问时,允许 Node 接收私有网络入站连接(TCP `3081`)
466
+ - VPN 场景下,确认 VPN 分配的是 RFC 1918 私有地址
467
+
468
+ ### 4. 配对 iOS 客户端
469
+
470
+ 1. 面板里填设备名称(如 `iPhone`),保持"设备鉴权"开启
471
+ 2. 开启"允许移动设备连接"
472
+ 3. 点击"生成配对二维码"
473
+ 4. iOS 客户端首页点认证按钮 → 扫码,或粘贴复制的 Base64URL 配对 Token
474
+ 5. 首次连接成功后长期凭证写入 Keychain;面板中设备显示"在线"即完成
475
+
476
+ 配对二维码**一次性使用、5 分钟过期**。之后启动 App 直接用 Keychain 凭证重连,不需要再扫码。重新配对同一套安装会复用设备身份,不会产生重复记录。
477
+
478
+ ### 5. 更新与卸载
479
+
480
+ ```bash
481
+ # 更新(插件不会自动更新)
482
+ dsh plugin --profile web remove dsh-plugin-mobile-gateway
483
+ dsh plugin --profile web add dsh-plugin-mobile-gateway
484
+ # 然后重启 dsh web
485
+
486
+ # 卸载(不会自动删除 ~/.dsh/mobile-gateway-devices.json)
487
+ dsh plugin --profile web remove dsh-plugin-mobile-gateway
488
+ ```
489
+
490
+ ### 6. 常见问题速查
491
+
492
+ | 现象 | 原因与处理 |
493
+ |---|---|
494
+ | 侧边栏没有"移动设备" | 确认用了 `--profile web`;`--dump-config` 检查组合树;**完整重启** `dsh web` |
495
+ | iOS 提示 `503` | 网关未开启,回 WebUI 打开"允许移动设备连接" |
496
+ | iOS 提示 `401` | 配对码过期 / 凭证无效 / 设备已被吊销;清除旧凭证重新配对 |
497
+ | iOS 提示 `403` | 来源不在私有网段(比如走了公网出口) |
498
+ | 真机连不上 `127.0.0.1` | 在手机上 `127.0.0.1` 指手机自己;用面板给出的私有 IP 地址 |
499
+ | 同 Wi-Fi 仍连不上 | 网络可能开了客户端隔离;放行电脑入站 TCP `3081` |
500
+ | 修改配置没生效 | 插件与组合配置只在启动时加载,需重启 WebUI |
501
+ | 需要排查服务端 | 看 `/tmp/mobile-gateway.log`,含连接 / 鉴权 / 查询 / 错误记录 |
502
+
503
+ ### 7. 源码开发
504
+
505
+ 只有需要修改插件本身时才用:
506
+
507
+ ```bash
508
+ dsh plugin --profile web add file:/absolute/path/to/dsh-plugin-mobile-gateway
509
+ ```
510
+
511
+ `file:` 是**复制安装**,改完源码必须先 remove 再 add 并重启。**不要用 `link:`**——依赖会从源码目录解析,导致 `ws` 等包找不到。
512
+
513
+ 测试:
514
+
515
+ ```bash
516
+ NODE_PATH=/path/to/dsh/node_modules node test/auth.test.mjs # 鉴权 / 配对 / 吊销
517
+ NODE_PATH=/path/to/dsh/node_modules node test/gateway.test.mjs # 完整协议链路
518
+ node test/lan.test.mjs # 局域网监听 / 强制鉴权 / 管理接口隔离
519
+ ```
520
+
521
+ ---
522
+
523
+ ## 七、小结
524
+
525
+ 这个插件在技术上不"炫"——没有新算法,没有花哨架构。它的价值在于把一件具体的事做干净了,并且在每个岔路口都选了更克制的那一边:
526
+
527
+ - 想要局域网访问时,**没有**去让宿主监听 `0.0.0.0`,而是自己开一个只做一件事的窄口径监听
528
+ - 给了 Debug 开关,但把它**限制在 loopback + 当前进程 + 重启复位**
529
+ - 长期凭证只在网线上出现一次,磁盘上永远只有摘要
530
+ - 公网能力没做完,就**老实标明"开发中"**,而不是先开个口子再补安全
531
+
532
+ 在 AI 让"写代码"变得越来越便宜的今天,这种克制反而是更稀缺的能力。
533
+
534
+ ---
535
+
536
+ ### 相关链接
537
+
538
+ - 项目仓库:[Clarklevis1995/dsh-plugin-mobile-gateway](https://github.com/Clarklevis1995/dsh-plugin-mobile-gateway)
539
+ - DeepSeek Harness:[deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness)
540
+ - 完整 WebSocket 协议:仓库内 `PROTOCOL.md`
541
+
542
+ *本文基于 dsh-plugin-mobile-gateway v0.4.2 源码撰写。公网连接能力开发中,后续会另文分享。*
package/lib/client.js CHANGED
@@ -37,13 +37,14 @@ window.__ModuleLoader__.load({
37
37
  },
38
38
  panel: {
39
39
  width: 380, maxWidth: 'calc(100vw - 24px)', height: '100%', overflowY: 'auto',
40
+ display: 'flex', flexDirection: 'column',
40
41
  background: colors.panel, color: colors.text, borderLeft: `1px solid ${colors.border}`,
41
42
  boxShadow: '-12px 0 40px rgba(0,0,0,.22)', padding: 20,
42
43
  fontFamily: 'system-ui, sans-serif', fontSize: 13, boxSizing: 'border-box',
43
44
  },
44
- header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 18 },
45
+ header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 18, flexShrink: 0 },
45
46
  title: { margin: 0, fontSize: 18, fontWeight: 650 },
46
- card: { background: colors.card, border: `1px solid ${colors.border}`, borderRadius: 12, padding: 14, marginBottom: 12 },
47
+ card: { background: colors.card, border: `1px solid ${colors.border}`, borderRadius: 12, padding: 14, marginBottom: 12, flexShrink: 0 },
47
48
  label: { display: 'block', color: colors.muted, fontSize: 12, marginBottom: 6 },
48
49
  input: { width: '100%', boxSizing: 'border-box', border: `1px solid ${colors.border}`, borderRadius: 8, padding: '8px 10px', marginBottom: 10, background: 'transparent', color: 'inherit', outline: 'none' },
49
50
  pairingText: { width: '100%', minHeight: 86, boxSizing: 'border-box', resize: 'vertical', border: `1px solid ${colors.border}`, borderRadius: 8, padding: '9px 10px', margin: '10px 0', background: 'transparent', color: 'inherit', outline: 'none', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 11, lineHeight: 1.45, overflowWrap: 'anywhere' },
@@ -59,6 +60,7 @@ window.__ModuleLoader__.load({
59
60
  switchInput: { position: 'absolute', opacity: 0, width: 1, height: 1 },
60
61
  switchTrack: { position: 'absolute', inset: 0, borderRadius: 99, transition: 'background .18s ease', cursor: 'pointer' },
61
62
  switchKnob: { position: 'absolute', top: 3, width: 18, height: 18, borderRadius: 99, background: '#fff', boxShadow: '0 1px 3px rgba(0,0,0,.35)', transition: 'left .18s ease', pointerEvents: 'none' },
63
+ version: { flexShrink: 0, marginTop: 'auto', padding: '18px 2px 0', color: colors.muted, fontSize: 11, lineHeight: 1.4, textAlign: 'right', letterSpacing: '.02em' },
62
64
  }
63
65
 
64
66
  async function request(path, options) {
@@ -373,7 +375,10 @@ window.__ModuleLoader__.load({
373
375
  device,
374
376
  onRevoke: revoke,
375
377
  revoking: revokingIds.has(device.id),
376
- })))))
378
+ }))),
379
+ status && status.version
380
+ ? React.createElement('div', { style: styles.version, 'aria-label': `插件版本 ${status.version}` }, `v${status.version}`)
381
+ : null))
377
382
  }
378
383
 
379
384
  function FooterButton(props) {
package/lib/index.mjs CHANGED
@@ -48,6 +48,8 @@
48
48
  // { "type": "agent-presets" } -> preset roster (+ isDefault)
49
49
  // { "type": "defaults" } -> default agent preset + default permission
50
50
  // { "type": "set-default", "target": "agent-preset"|"permission", "value" }
51
+ // { "type": "question-answer", "rpcId", "sessionId", "answers": [...] }
52
+ // { "type": "question-cancel", "rpcId", "sessionId" }
51
53
  // server -> client: { "kind": "hello", "protocol": 2, "authenticated", "device"?, "port", "clients" }
52
54
  // { "kind": "pong", "at" }
53
55
  // { "kind": "subscribed", "sessionId" }
@@ -55,6 +57,9 @@
55
57
  // { "kind": "workspaces" | "sessions" | "history" | "search", ...data }
56
58
  // { "kind": "error", "code", "message", "requestType"?, "sessionId"? }
57
59
  // { "kind": "event", "sessionId", "seq", "time", "event": { ... } }
60
+ // { "kind": "question-requested", "rpcId", "sessionId", "questions", "replay"? }
61
+ // { "kind": "question-response", "rpcId", "sessionId", "action", "accepted", "reason"? }
62
+ // { "kind": "question-resolved", "rpcId", "sessionId", "outcome" }
58
63
  import fs from 'node:fs'
59
64
  import fsp from 'node:fs/promises'
60
65
  import http from 'node:http'
@@ -68,6 +73,13 @@ import QRCode from 'qrcode'
68
73
 
69
74
  const { createRegistry } = devicesModule
70
75
 
76
+ const PLUGIN_VERSION = (() => {
77
+ try {
78
+ return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
79
+ } catch {
80
+ return 'unknown'
81
+ }
82
+ })()
71
83
  const MAX_PREVIEW = 400
72
84
  const LOG_FILE = '/tmp/mobile-gateway.log'
73
85
  const DEFAULT_WS_PATH = '/ws/mobile'
@@ -851,6 +863,8 @@ const plugin = {
851
863
  },
852
864
  })
853
865
  const clients = new Set()
866
+ const pendingQuestions = new Map()
867
+ const questionMuxAbort = new AbortController()
854
868
  let counter = 0
855
869
  let gatewayEnabled = options.gatewayEnabled === true
856
870
  let waitExpiresAt = null
@@ -912,6 +926,72 @@ const plugin = {
912
926
  }
913
927
  }
914
928
 
929
+ const questionFrameFor = (rpcId, payload, replay = false) => ({
930
+ kind: 'question-requested',
931
+ rpcId: String(rpcId),
932
+ sessionId: String(payload.sessionId),
933
+ questions: payload.questions,
934
+ ...(replay ? { replay: true } : {}),
935
+ })
936
+
937
+ const broadcastQuestionFrame = (frame) => {
938
+ const wire = JSON.stringify(frame)
939
+ for (const client of clients) {
940
+ if (client.filterSessionId && client.filterSessionId !== frame.sessionId) continue
941
+ if (client.readyState === 1) client.send(wire)
942
+ }
943
+ }
944
+
945
+ const respondToQuestion = async (msg, cancel = false) => {
946
+ const rpcId = typeof msg.rpcId === 'string' && msg.rpcId.trim() ? msg.rpcId.trim() : null
947
+ const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() ? msg.sessionId.trim() : null
948
+ if (!rpcId || !sessionId) {
949
+ return { kind: 'error', code: 'bad-request', message: `${msg.type} requires rpcId and sessionId`, requestType: msg.type }
950
+ }
951
+
952
+ if (cancel) {
953
+ const pending = pendingQuestions.get(rpcId)
954
+ if (pending && pending.sessionId !== sessionId) {
955
+ return { kind: 'error', code: 'bad-request', message: 'sessionId does not match the pending question', requestType: msg.type, sessionId }
956
+ }
957
+ } else if (!Array.isArray(msg.answers)) {
958
+ return { kind: 'error', code: 'bad-request', message: 'question-answer requires an answers array', requestType: msg.type, sessionId }
959
+ }
960
+
961
+ const result = cancel
962
+ ? { ok: false, error: { code: 'cancelled', message: 'question cancelled by mobile user', details: {} } }
963
+ : {
964
+ ok: true,
965
+ value: {
966
+ sessionId,
967
+ answer: {
968
+ answers: msg.answers.map((answer) => ({
969
+ id: answer && answer.id,
970
+ selected: answer && answer.selected,
971
+ ...(answer && answer.custom !== undefined ? { custom: answer.custom } : {}),
972
+ })),
973
+ },
974
+ },
975
+ }
976
+
977
+ try {
978
+ const receipt = await api.respond({ type: 'client-response', rpcId, result })
979
+ log(`question response: rpcId=${rpcId} session=${sessionId} action=${cancel ? 'cancel' : 'answer'} accepted=${receipt.accepted}${receipt.accepted ? '' : ` reason=${receipt.reason}`}`)
980
+ return {
981
+ kind: 'question-response',
982
+ rpcId,
983
+ sessionId,
984
+ action: cancel ? 'cancel' : 'answer',
985
+ accepted: receipt.accepted,
986
+ ...(!receipt.accepted ? { reason: receipt.reason } : {}),
987
+ }
988
+ } catch (error) {
989
+ const message = error && error.message ? error.message : String(error)
990
+ log(`question response failed: rpcId=${rpcId} ${message}`)
991
+ return { kind: 'error', code: 'internal', message, requestType: msg.type, sessionId }
992
+ }
993
+ }
994
+
915
995
  if (gatewayEnabled) setGatewayEnabled(true, 'enabled by startup config')
916
996
 
917
997
  log(`applying: path=${wsPath}, webServer.port=${webServer.port}, gatewayEnabled=${gatewayEnabled}, requireAuth=${requireAuth}, devices=${registry.count()}`)
@@ -934,6 +1014,7 @@ const plugin = {
934
1014
  const p = url.pathname
935
1015
  if (req.method === 'GET' && p === '/mgw/status') {
936
1016
  sendJson(res, 200, {
1017
+ version: PLUGIN_VERSION,
937
1018
  requireAuth,
938
1019
  gatewayEnabled,
939
1020
  waitExpiresAt,
@@ -1097,6 +1178,10 @@ const plugin = {
1097
1178
  } else if (msg.type === 'unsubscribe') {
1098
1179
  ws.filterSessionId = undefined
1099
1180
  ws.send(JSON.stringify({ kind: 'subscribed', sessionId: null }))
1181
+ } else if (msg.type === 'question-answer' || msg.type === 'question-cancel') {
1182
+ respondToQuestion(msg, msg.type === 'question-cancel').then((frame) => {
1183
+ if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
1184
+ })
1100
1185
  } else if (msg.type === 'message') {
1101
1186
  admitMessage(api, msg).then((frame) => {
1102
1187
  if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
@@ -1138,6 +1223,10 @@ const plugin = {
1138
1223
  authenticated: !!device,
1139
1224
  ...(device ? { device: { id: device.id, name: device.name } } : {}),
1140
1225
  }))
1226
+ for (const pending of pendingQuestions.values()) {
1227
+ if (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
1228
+ ws.send(JSON.stringify(questionFrameFor(pending.rpcId, pending, true)))
1229
+ }
1141
1230
  })
1142
1231
  }
1143
1232
 
@@ -1202,8 +1291,51 @@ const plugin = {
1202
1291
  })
1203
1292
  log('session/event listener attached')
1204
1293
 
1294
+ const questionMuxTask = api.events && typeof api.events.mux === 'function' && typeof api.respond === 'function'
1295
+ ? (async () => {
1296
+ try {
1297
+ for await (const envelope of api.events.mux(
1298
+ { rpcId: crypto.randomUUID(), payload: {} },
1299
+ questionMuxAbort.signal,
1300
+ )) {
1301
+ const payload = envelope && envelope.payload
1302
+ if (!payload || typeof payload.type !== 'string') continue
1303
+ if (payload.type === 'question/requested') {
1304
+ const rpcId = String(envelope.rpcId)
1305
+ const pending = {
1306
+ rpcId,
1307
+ sessionId: String(payload.sessionId),
1308
+ questions: payload.questions,
1309
+ }
1310
+ pendingQuestions.set(rpcId, pending)
1311
+ broadcastQuestionFrame(questionFrameFor(rpcId, pending))
1312
+ log(`question requested: rpcId=${rpcId} session=${pending.sessionId} questions=${Array.isArray(pending.questions) ? pending.questions.length : 0}`)
1313
+ } else if (payload.type === 'question/resolved') {
1314
+ const questionRpcId = String(payload.questionRpcId)
1315
+ pendingQuestions.delete(questionRpcId)
1316
+ broadcastQuestionFrame({
1317
+ kind: 'question-resolved',
1318
+ rpcId: questionRpcId,
1319
+ sessionId: String(payload.sessionId),
1320
+ outcome: payload.outcome,
1321
+ })
1322
+ log(`question resolved: rpcId=${questionRpcId} session=${payload.sessionId} outcome=${payload.outcome}`)
1323
+ }
1324
+ }
1325
+ } catch (error) {
1326
+ if (!questionMuxAbort.signal.aborted) {
1327
+ log(`question mux failed: ${error && error.message ? error.message : String(error)}`)
1328
+ }
1329
+ }
1330
+ })()
1331
+ : null
1332
+ if (questionMuxTask) log('api gateway question mux attached')
1333
+ else log('api gateway question mux unavailable; human-in-the-loop disabled')
1334
+
1205
1335
  ctx.effect(() => () => {
1206
1336
  if (waitTimer) clearTimeout(waitTimer)
1337
+ questionMuxAbort.abort()
1338
+ pendingQuestions.clear()
1207
1339
  disposeUpgrade()
1208
1340
  disposeMgmt()
1209
1341
  disposeEvents()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-gateway",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Persistent WebSocket gateway for dsh: exposes /ws/mobile on the web server and forwards agent session output to connected mobile clients",
5
5
  "main": "lib/index.mjs",
6
6
  "files": [