dsh-plugin-mobile-gateway 0.6.4 → 0.6.8

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 实时输出、发送文字和图片、处理 Human-in-the-loop 提问、查询会话/工作区/历史、调整会话配置。本协议由持久化插件 `dsh-plugin-mobile-gateway` 实现(v0.6.3)。
3
+ 移动端通过一个经过设备鉴权的 WebSocket 连接与 dsh 通信:订阅 agent 实时输出、发送文字和图片、处理 Human-in-the-loop 提问与操作审批、查询会话/工作区/历史、调整会话配置。本协议由持久化插件 `dsh-plugin-mobile-gateway` 实现(v0.6.8)。
4
4
 
5
5
  - **本机端点**:`ws://127.0.0.1:3080/ws/mobile`(与 dsh web GUI 同端口)
6
6
  - **局域网端点**:`ws://<电脑的私有局域网 IP>:3081/ws/mobile`(插件独立监听,只提供经过鉴权的 WebSocket)
@@ -77,7 +77,7 @@ const pairingText = Buffer.from(JSON.stringify(payload), 'utf8').toString('base6
77
77
  ```json
78
78
  { "kind": "paired", "token": "<长期设备 token>",
79
79
  "device": { "id": "...", "name": "iPhone", "createdAt": 1787111700000 } }
80
- { "kind": "hello", "protocol": 3, "capabilities": ["images"], "authenticated": true,
80
+ { "kind": "hello", "protocol": 3, "capabilities": ["images", "file-downloads"], "authenticated": true,
81
81
  "device": { "id": "...", "name": "iPhone" }, "port": 3080, "clients": 1 }
82
82
  ```
83
83
 
@@ -138,7 +138,7 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
138
138
  | type | 参数 | 说明 |
139
139
  |---|---|---|
140
140
  | `ping` | — | 心跳;回复 `pong` |
141
- | `subscribe` | `sessionId` | 事件流过滤:之后只收到该会话的 `event`(不订阅 = 接收所有会话) |
141
+ | `subscribe` | `sessionId` | 事件流过滤:之后只收到该会话的 `event`,并重放该会话仍待处理的提问与审批(不订阅 = 接收所有会话) |
142
142
  | `unsubscribe` | — | 取消过滤 |
143
143
 
144
144
  ```json
@@ -149,13 +149,26 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
149
149
  → {"kind":"subscribed","sessionId":"session-abc"}
150
150
  ```
151
151
 
152
+ `subscribed` 之后,服务端会紧接着发送该 Session 尚未处理的
153
+ `question-requested` / `approval-requested`,并标记 `replay: true`。客户端必须按
154
+ `rpcId` 去重。这保证移动端在审批产生后才打开已有 Session 时仍能显示待处理卡片。
155
+
152
156
  ---
153
157
 
154
- ## 3. Human-in-the-loop 提问与回答
158
+ ## 3. Human-in-the-loop
159
+
160
+ Human-in-the-loop 分为两条独立通道:
161
+
162
+ - **提问**:Agent 的 `ask_user_question` 工具向用户收集答案。
163
+ - **审批**:高风险工具操作(例如沙箱升权)请求一次性允许或拒绝。
164
+
165
+ 二者都是 API Gateway 的临时请求,不属于持久化的 `session/event`,且都必须以其原始 `rpcId` 通过专用响应帧回答,不能作为普通 `message` 发送。
155
166
 
156
- Agent 调用 DSH 的 `ask_user_question` 工具时,插件通过 API Gateway 的 `events.mux()` 收到临时的待回答请求,并推送给移动端。该请求不属于持久化的 `session/event`;回答必须使用本节协议,不能作为普通 `message` 发送。
167
+ ### 3.1 提问与回答
157
168
 
158
- ### `question-requested` 服务端推送问题
169
+ Agent 调用 DSH 的 `ask_user_question` 工具时,插件通过 API Gateway 的 `events.mux()` 收到临时的待回答请求,并推送给移动端。
170
+
171
+ #### `question-requested` — 服务端推送问题
159
172
 
160
173
  ```json
161
174
  {
@@ -187,7 +200,7 @@ Agent 调用 DSH 的 `ask_user_question` 工具时,插件通过 API Gateway
187
200
  - `intent`:可选展示意图。目前可能为 `{ "kind":"plan-review", "approve":"批准选项标签" }`;未知 intent 应退化为普通选项列表。
188
201
  - `replay: true`:可选。表示这是移动端连接后重放的仍待回答问题。iOS 必须按 `rpcId` 去重。
189
202
 
190
- ### `question-answer` — 移动端提交整批答案
203
+ #### `question-answer` — 移动端提交整批答案
191
204
 
192
205
  ```json
193
206
  {
@@ -236,7 +249,7 @@ Agent 调用 DSH 的 `ask_user_question` 工具时,插件通过 API Gateway
236
249
 
237
250
  答案结构不合法时 `reason` 为 `bad-response`。这两种情况均不能重发为普通聊天消息。
238
251
 
239
- ### `question-cancel` — 跳过/取消整批问题
252
+ #### `question-cancel` — 跳过/取消整批问题
240
253
 
241
254
  ```json
242
255
  { "type":"question-cancel", "rpcId":"5ce4f5d1-...", "sessionId":"session-abc" }
@@ -244,7 +257,7 @@ Agent 调用 DSH 的 `ask_user_question` 工具时,插件通过 API Gateway
244
257
 
245
258
  回执仍为 `question-response`,其中 `action` 为 `cancel`。取消会让等待中的 `ask_user_question` 以 `ASK_CANCELLED` 结束,iOS 应在用户确认后再执行。
246
259
 
247
- ### `question-resolved` — 服务端广播最终状态
260
+ #### `question-resolved` — 服务端广播最终状态
248
261
 
249
262
  ```json
250
263
  { "kind":"question-resolved", "rpcId":"5ce4f5d1-...", "sessionId":"session-abc",
@@ -255,6 +268,72 @@ Agent 调用 DSH 的 `ask_user_question` 工具时,插件通过 API Gateway
255
268
 
256
269
  ---
257
270
 
271
+ ### 3.2 操作审批
272
+
273
+ 当 DSH 的工具管线要求人工授权时,插件会从 API Gateway 收到一次 `approval/requested`。这正是 Web UI 中“等待审批”卡片对应的事件:`reason` 是面向用户的审批说明,`toolName` 标识请求操作的工具,`callId` 可用于与实时工具调用轨迹关联。
274
+
275
+ #### `approval-requested` — 服务端推送待审批操作
276
+
277
+ ```json
278
+ {
279
+ "kind": "approval-requested",
280
+ "rpcId": "approval-rpc-1",
281
+ "sessionId": "session-abc",
282
+ "approvalId": "approval-1",
283
+ "toolName": "bash",
284
+ "callId": "call-42",
285
+ "reason": "escalate sandbox to danger-full-access",
286
+ "replay": true
287
+ }
288
+ ```
289
+
290
+ - `rpcId`:本次可回答请求的稳定 RPC ID;提交决定时必须原样返回。
291
+ - `approvalId`:审批审计 ID;同样必须原样返回,并用于将最终状态关联到本地审批卡片。
292
+ - `toolName`:请求审批的工具名。
293
+ - `callId` / `reason`:可选。前者可关联工具调用,后者应直接显示为待审批原因。
294
+ - `replay: true`:表示当前仍未决定的审批在移动端连接或切换 Session 后重放。客户端应按 `rpcId` 去重。
295
+
296
+ 审批请求不含工具完整参数;移动端应将 `reason` 与可见的工具调用轨迹作为展示依据,不应自行推断或构造命令。
297
+
298
+ #### `approval-response` — 移动端提交决定
299
+
300
+ ```json
301
+ {
302
+ "type": "approval-response",
303
+ "rpcId": "approval-rpc-1",
304
+ "sessionId": "session-abc",
305
+ "approvalId": "approval-1",
306
+ "outcome": "allowed-once"
307
+ }
308
+ ```
309
+
310
+ `outcome` 只能是:
311
+
312
+ - `allowed-once`:仅允许这一次请求的操作。
313
+ - `rejected`:拒绝该操作。
314
+
315
+ 这是一次性决定;协议不支持“始终允许”。`cancelled` 与 `unavailable` 是宿主侧状态,移动端不得提交。请求的 `sessionId`、`approvalId` 与 `rpcId` 必须匹配同一待审批项。
316
+
317
+ 网关立即返回交付回执:
318
+
319
+ ```json
320
+ { "kind":"approval-response", "rpcId":"approval-rpc-1", "sessionId":"session-abc",
321
+ "approvalId":"approval-1", "outcome":"allowed-once", "accepted":true }
322
+ ```
323
+
324
+ 如果 Web UI 或另一台移动设备已经先作出决定,则回执为 `accepted:false`,并附带 `reason:"not-pending"`。收到错误帧或未被接受的回执时,客户端应保留当前状态,等待最终状态或重新打开事件流。
325
+
326
+ #### `approval-resolved` — 服务端广播最终状态
327
+
328
+ ```json
329
+ { "kind":"approval-resolved", "rpcId":"approval-rpc-1", "sessionId":"session-abc",
330
+ "approvalId":"approval-1", "outcome":"allowed-once" }
331
+ ```
332
+
333
+ `outcome` 为 `allowed-once`、`rejected`、`cancelled` 或 `unavailable`。所有移动连接都会收到最终状态并关闭对应审批卡片。移动端断线重连后,API Gateway 会重放仍待决定的审批;已决审批不会重放。
334
+
335
+ ---
336
+
258
337
  ## 4. 消息(手机 → agent)
259
338
 
260
339
  ### `message` — 发送消息(会话不存在则创建)
@@ -322,6 +401,10 @@ let image = [
322
401
  | `sessions` | — | 会话列表(`updatedAt/running/blank/cwd/agentPreset`) |
323
402
  | `history` | `sessionId`, `beforeSeq?`, `maxMessages?`, `maxBytes?`, `view?` | 历史事件页(见下) |
324
403
  | `attachment` | `sessionId`, `attachmentId` | 读取历史中属于该会话的图片字节 |
404
+ | `file-list` | `sessionId`, `path?`, `requestId?` | 列出会话工作目录内的一层文件与文件夹 |
405
+ | `file-download-open` | `sessionId`, `path`, `requestId` | 打开一个工作目录内的普通文件下载 |
406
+ | `file-download-read` | `transferId`, `offset` | 拉取下载的下一块字节 |
407
+ | `file-download-cancel` | `transferId` | 取消并关闭下载 |
325
408
  | `search` | `query` | 会话全文搜索 |
326
409
  | `session-stats` | `sessionId` | 执行统计投影(输入框统计条数据源) |
327
410
  | `context-usage` | `sessionId` | token 用量 + 上下文占用投影 |
@@ -384,6 +467,46 @@ iOS 发现尚未缓存的 `attachmentId` 后发送:
384
467
 
385
468
  iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建议以 `attachmentId` 为缓存键。不要把 Base64 长期保存在对话模型对象里。并发同步历史时可限制为 2~4 个附件请求,优先加载当前可见消息。
386
469
 
470
+ ### 文件下载(图片、文档、IPA、APK 及其他普通文件)
471
+
472
+ 文件下载是独立于历史图片 `attachment` 的二进制传输通道。它不按扩展名做授权白名单:图片、PDF/Office 文档、`.ipa`、`.apk` 和其他**普通文件**均可下载;服务端仅根据扩展名给出 `mediaType`,以便移动端决定打开方式。
473
+
474
+ 所有 `path` 都是相对于该 `sessionId` 的 `cwd` 的相对路径,使用 `/` 分隔。例如先列出根目录:
475
+
476
+ ```json
477
+ { "type": "file-list", "requestId": "files-1", "sessionId": "session-abc" }
478
+ → {
479
+ "kind": "file-list", "requestId": "files-1", "sessionId": "session-abc", "path": ".",
480
+ "entries": [
481
+ { "name": "builds", "path": "builds", "kind": "directory" },
482
+ { "name": "app.ipa", "path": "app.ipa", "kind": "file", "bytes": 123456,
483
+ "modifiedAt": 1787111700000, "mediaType": "application/octet-stream" }
484
+ ]
485
+ }
486
+ ```
487
+
488
+ 打开并按需拉取每一块:
489
+
490
+ ```json
491
+ { "type": "file-download-open", "requestId": "download-1", "sessionId": "session-abc", "path": "builds/app-release.apk" }
492
+ → { "kind": "file-download-opened", "requestId": "download-1", "transferId": "...",
493
+ "sessionId": "session-abc", "path": "builds/app-release.apk", "name": "app-release.apk",
494
+ "mediaType": "application/vnd.android.package-archive", "size": 2345678, "chunkBytes": 524288 }
495
+
496
+ { "type": "file-download-read", "transferId": "...", "offset": 0 }
497
+ → { "kind": "file-download-chunk", "transferId": "...", "offset": 0,
498
+ "data": "<标准 Base64>", "eof": false }
499
+
500
+ { "type": "file-download-read", "transferId": "...", "offset": 524288 }
501
+ → { "kind": "file-download-chunk", "transferId": "...", "offset": 524288,
502
+ "data": "<标准 Base64>", "eof": true, "sha256": "<64 位十六进制摘要>" }
503
+ ```
504
+
505
+ - 客户端必须严格使用服务端返回块的 `offset + 已解码 data 字节数` 作为下一次 `offset`;当前版本不支持断线续传。请先写入临时文件,收到 `eof: true` 后校验整文件 SHA-256,再原子重命名为最终文件。
506
+ - 每个 `transferId` 仅归属创建它的 WebSocket 连接。连接关闭、`file-download-cancel`、空闲 2 分钟、传完最后一块或插件卸载都会关闭文件句柄;取消成功返回 `{ "kind":"file-download-cancelled", "transferId":"..." }`。
507
+ - 默认每块为 512 KiB、同时最多 4 个下载、单个文件最多 512 MiB。部署方可用 `fileDownloadChunkBytes`、`fileDownloadMaxTransfers`、`fileDownloadMaxBytes`、`fileDownloadIdleMs` 调整;`fileDownloadsEnabled: false` 会关闭该能力,且 `hello.capabilities` 不再包含 `file-downloads`。
508
+ - 绝对路径、空路径(`file-download-open`)、`..` 路径段、NUL 字符、工作目录外的符号链接、目录和其他非普通文件都会被拒绝。`file-list` 不返回符号链接,避免客户端误认为其可下载。
509
+
387
510
  ### `session-stats` 详细(输入框统计条)
388
511
  ```json
389
512
  { "type": "session-stats", "sessionId": "session-abc" }
@@ -523,9 +646,10 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
523
646
  | kind | 触发时机 |
524
647
  |---|---|
525
648
  | `paired` | 首次配对成功;仅此一次返回长期设备 token |
526
- | `hello` | 连接成功:`{ "kind":"hello", "protocol":3, "capabilities":["images"], "authenticated":true, "port":3080, "clients":1 }` |
649
+ | `hello` | 连接成功:`{ "kind":"hello", "protocol":3, "capabilities":["images","file-downloads"], "authenticated":true, "port":3080, "clients":1 }` |
527
650
  | `event` | 任意会话的 agent 输出(见下) |
528
651
  | `question-requested` / `question-resolved` | Human-in-the-loop 问题请求与最终状态 |
652
+ | `approval-requested` / `approval-resolved` | Human-in-the-loop 操作审批请求与最终状态 |
529
653
  | `pong` / `subscribed` / `sent` | 对应请求的回复 |
530
654
 
531
655
  ### `event` 帧(agent 实时输出)
@@ -566,6 +690,8 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
566
690
  - 长期 token 只保存在 iOS Keychain;服务端磁盘仅保存摘要
567
691
  - `set-default` / `save-default-model` 是全局写操作,客户端 UI 应加确认
568
692
  - `question-answer` / `question-cancel` 会直接恢复或终止等待中的 Agent 工具调用;只允许经过鉴权的可信设备提交,并按 `rpcId` 防止重复操作
693
+ - `approval-response` 会直接允许或拒绝等待中的高风险工具操作;只允许经过鉴权的可信设备提交,并按 `rpcId` 和 `approvalId` 防止串用或重复操作
694
+ - 文件下载只允许读取该会话 `cwd` 内的普通文件;移动端必须在写入完成后校验最终块给出的 SHA-256,且不得把 `transferId` 视为可跨连接复用的凭证
569
695
 
570
696
  ---
571
697
 
@@ -589,8 +715,11 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
589
715
  | v0.3.0 | 默认设备鉴权;一次性二维码配对;摘要化凭证存储;WebUI 设备面板;在线状态和即时吊销 |
590
716
  | v0.5.0 | Human-in-the-loop:转发 API Gateway question 请求、整批回答/取消、重连重放与多端状态收敛 |
591
717
  | v0.6.0 | DSH 0.1.1 图片:WebSocket Base64 上传、实时图片引用、历史附件按会话安全读取 |
592
- | v0.6.3 | macOS native picker 兼容:目录创建改用与目录浏览一致的宿主文件系统实现,并补齐路径、名称和错误码校验 |
593
718
  | v0.6.2 | 目录创建:通过 API Gateway `host.createDirectory` 在工作区目录下创建子文件夹 |
719
+ | v0.6.3 | macOS native picker 兼容:目录创建改用与目录浏览一致的宿主文件系统实现,并补齐路径、名称和错误码校验 |
720
+ | v0.6.6 | Human-in-the-loop 操作审批:转发 API Gateway approval 请求、一次性允许/拒绝、重连重放与多端最终状态收敛 |
721
+ | v0.6.7 | 订阅已有 Session 时重放待处理 Human-in-the-loop 请求,并增加 Approval 端到端诊断日志与安装版本标记 |
722
+ | v0.6.8 | 会话工作目录受限的文件列表与分块下载:支持图片、文档、IPA、APK 等普通文件,含连接归属、路径越界防护、取消、超时和 SHA-256 完整性校验 |
594
723
 
595
724
  ---
596
725
 
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  - WebSocket:`/ws/mobile`
10
10
  - 局域网:`ws://<局域网 IP>:3081/ws/mobile`
11
- - 公网:`wss://<公网 IP>/ws/mobile`
11
+ - Linux 服务器公网:`wss://<公网 IP>/ws/mobile`
12
12
  - 协议文档:[PROTOCOL.md](PROTOCOL.md)
13
13
 
14
14
  ## 配套 iOS 客户端
@@ -30,7 +30,7 @@
30
30
 
31
31
  ## 安装插件
32
32
 
33
- 前提:已经安装 `dsh` CLI,并能正常启动 `dsh web`。
33
+ 前提:已经安装 `dsh` CLI `pnpm`,并能正常启动 `dsh web`。可先执行 `pnpm --version` 确认当前用户的环境能够找到 `pnpm`。
34
34
 
35
35
  局域网使用只需安装插件:
36
36
 
@@ -38,7 +38,7 @@
38
38
  dsh plugin --profile web add dsh-plugin-mobile-gateway@latest
39
39
  ```
40
40
 
41
- 需要公网接入时,推荐执行统一初始化命令。它会安装/更新插件,并请求一次 sudo 权限安装系统 Helper:
41
+ 需要在 Linux 服务器通过公网 IP 接入时,推荐执行统一初始化命令。它会安装/更新插件,并请求一次 sudo 权限安装系统 Helper:
42
42
 
43
43
  ```bash
44
44
  npx --yes dsh-plugin-mobile-gateway@latest init
@@ -68,35 +68,41 @@ dsh web
68
68
 
69
69
  如果系统防火墙拦截连接,只允许私有网络访问 TCP `3081`。不要把 3081 开放到公网。
70
70
 
71
- ## 公网 IP 配对
71
+ ## Linux 服务器公网 IP 配对
72
72
 
73
- 适用于带固定公网 IPv4 的 Ubuntu/Debian 服务器(其他发行版本可自行尝试)。Node.js 通过 nvm 安装时,也使用下面的命令。
73
+ > [!NOTE]
74
+ > 本文所说的“公网 IP 配对”特指 Linux 服务器。一键公网安装从 `v0.6.4` 开始提供,适用于带固定公网 IPv4 的 Ubuntu/Debian 服务器。服务器需要已经安装 Node.js、`pnpm` 和 `dsh` CLI;当前尚不支持 CentOS。
74
75
 
75
- ### 1. 配置端口
76
+ ### 1. 准备公网端口
76
77
 
77
- 从云厂商控制台复制服务器的公网 IPv4,并在安全组中放行入站 TCP `80` 和 `443`。
78
+ 在云厂商控制台复制服务器的公网 IPv4,并在安全组中放行入站 TCP `80` 和 `443`。不要将 DSH WebUI 端口或 TCP `3081` 开放到公网。
78
79
 
79
- 不要开放 TCP `3080` 和 `3081`!!!
80
- 不要开放 TCP `3080` 和 `3081`!!!
81
- 不要开放 TCP `3080` 和 `3081`!!!
80
+ ### 2. 一键初始化
82
81
 
83
- ### 2. 配置公网入口
82
+ 先确认普通 DSH 用户可以直接调用 `pnpm`:
84
83
 
85
- 执行一次初始化(已经执行过可跳过):
84
+ ```bash
85
+ pnpm --version
86
+ ```
87
+
88
+ 然后使用同一个普通用户执行(不要使用 `root` 或 `sudo npx`):
86
89
 
87
90
  ```bash
91
+ npm_config_registry=https://registry.npmjs.org \
88
92
  npx --yes dsh-plugin-mobile-gateway@latest init
89
93
  ```
90
94
 
91
- 然后启动或重新启动:
95
+ 该命令会安装或更新插件,并请求一次 sudo 权限安装 Nginx、Certbot、系统 Helper 和证书续期定时器。`init` 会确保 DSH profile 与 Helper 使用同一个精确版本。
96
+
97
+ 完成后启动或重新启动 WebUI:
92
98
 
93
99
  ```bash
94
100
  dsh web
95
101
  ```
96
102
 
97
- ### 3. 打开远程 WebUI(使用端口转发,VSCode等IDE自带)
103
+ ### 3. 打开远程 WebUI
98
104
 
99
- 如果 WebUI 运行在远程服务器,在自己的电脑执行:
105
+ 优先使用 VS Code、Cursor 等 IDE 自带的端口转发。也可以在自己的电脑执行:
100
106
 
101
107
  ```bash
102
108
  ssh -N -L <本地端口>:127.0.0.1:<DSH 实际端口> <服务器用户名>@<服务器公网 IP>
@@ -108,18 +114,67 @@ ssh -N -L <本地端口>:127.0.0.1:<DSH 实际端口> <服务器用户名>@<服
108
114
  http://127.0.0.1:<本地端口>
109
115
  ```
110
116
 
111
- ### 4. 使用 WebUI 配对
117
+ ### 4. UI 配置公网入口
118
+
119
+ 打开左侧的“移动设备”,在“公网接入”填写云厂商控制台提供的公网 IPv4,然后点击“配置公网接入”或“更新公网配置”。Helper 会自动读取当前 `dsh web` 端口并配置 Nginx、TLS 证书和 `wss://<公网 IP>/ws/mobile`。
120
+
121
+ <p align="center">
122
+ <img src="docs/assets/public-access-ui.png" alt="在移动设备面板配置公网接入" width="420">
123
+ </p>
124
+
125
+ ### 5. 配对移动设备
112
126
 
113
- 1. 打开“移动设备”,在“公网接入”中填写云厂商控制台提供的公网 IPv4。
114
- 2. 点击“配置公网接入”。Helper 会自动使用当前 `dsh web` 端口配置 Nginx 和证书。
115
- 3. 开启“允许移动设备连接”,保持“设备鉴权”开启。
116
- 4. 填写设备名称并点击“生成配对二维码”。
117
- 5. iPhone 打开“设备认证”并扫描二维码。
118
- 6. WebUI 的可信设备显示“在线”后即完成。
127
+ 1. 开启“允许移动设备连接”,保持“设备鉴权”开启。
128
+ 2. 填写设备名称并点击“生成配对二维码”。
129
+ 3. iPhone 打开“设备认证”并扫描二维码。
130
+ 4. WebUI 的可信设备显示“在线”后即完成。
119
131
 
120
132
  二维码只能使用一次,并会在 5 分钟后过期;超时后在 WebUI 重新生成即可。
121
133
 
122
- ## 公网入口管理
134
+ ## Windows / macOS 家用电脑远程连接
135
+
136
+ 家用电脑通常没有固定公网 IP,不建议配置路由器端口转发。可以使用 Tailscale 长期连接,或使用 Cloudflare Quick Tunnel 临时调试。两种方式都转发到插件专用的 `3081` 端口,不会公开 DSH WebUI。
137
+
138
+ 使用前先启动 `dsh web`,并在“移动设备”面板开启“允许移动设备连接”和“设备鉴权”。
139
+
140
+ ### Tailscale(推荐长期使用)
141
+
142
+ 1. 在电脑和 iPhone 安装 [Tailscale](https://tailscale.com/download),并登录同一个 Tailnet。
143
+ 2. 在 Windows PowerShell 或 macOS 终端执行:
144
+
145
+ ```bash
146
+ tailscale serve --bg 3081
147
+ ```
148
+
149
+ 3. 执行 `tailscale serve status` 查看生成的 `https://<设备名>.<tailnet>.ts.net` 地址。
150
+ 4. 将地址改为 `wss://<设备名>.<tailnet>.ts.net/ws/mobile`,填入 WebUI 的“WebSocket 地址”,再生成二维码配对。
151
+
152
+ Tailscale Serve 只允许同一 Tailnet 中符合访问规则的设备连接,并自动提供 HTTPS。可用 `tailscale serve reset` 停止转发。参见 [Tailscale Serve 文档](https://tailscale.com/docs/reference/tailscale-cli/serve)。
153
+
154
+ ### Cloudflare Quick Tunnel(仅临时调试)
155
+
156
+ 1. 安装 [cloudflared](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/)。
157
+ 2. 在 Windows PowerShell 或 macOS 终端执行:
158
+
159
+ ```bash
160
+ cloudflared tunnel --url http://127.0.0.1:3081
161
+ ```
162
+
163
+ 3. 命令行会显示随机的 `https://<随机名称>.trycloudflare.com` 地址。
164
+ 4. 将地址改为 `wss://<随机名称>.trycloudflare.com/ws/mobile`,填入 WebUI 的“WebSocket 地址”,再生成二维码配对。
165
+
166
+ 保持该命令运行;停止命令后隧道立即失效。Quick Tunnel 的地址每次可能变化,且没有可用性保证,不适合正式或长期使用。公网调试时必须保持“设备鉴权”开启。参见 [Cloudflare Quick Tunnel 文档](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/)。
167
+
168
+ ## 网关配置方式总览
169
+
170
+ | 使用场景 | 推荐入口 | iOS WebSocket 地址 | 需要的额外配置 | 端口与鉴权 |
171
+ |---|---|---|---|---|
172
+ | 同一局域网 | 插件局域网入口 | `ws://<电脑局域网 IP>:3081/ws/mobile` | 无需 Helper 或 Nginx;电脑与 iPhone 位于可互访的局域网 | 仅对私有网络放行 TCP `3081`;保持鉴权开启 |
173
+ | 本机 iOS 模拟器 | DSH WebUI 本地入口 | `ws://127.0.0.1:<DSH WebUI 端口>/ws/mobile` | 无需 Helper、Nginx 或独立的 `3081` 端口 | 不开放任何外部端口;仅 Debug 时可关闭鉴权 |
174
+ | Linux 公网服务器 | 插件 Helper + Nginx + TLS | `wss://<服务器公网 IPv4>/ws/mobile` | 执行 `init`,再从 WebUI 填写公网 IPv4 | 云安全组放行 TCP `80/443`;不要公开 DSH 端口和 `3081`;必须鉴权 |
175
+ | 家用 Windows / macOS | Tailscale Serve;临时调试可用 Quick Tunnel | `wss://<Tailscale 域名>/ws/mobile` 或 `wss://<随机名称>.trycloudflare.com/ws/mobile` | 隧道转发到 `127.0.0.1:3081`,将生成的地址填入 WebUI | 无需路由器端口转发;保持鉴权开启 |
176
+
177
+ ## Linux 服务器公网入口管理
123
178
 
124
179
  查看状态:
125
180
 
@@ -162,8 +217,8 @@ sudo env "PATH=$PATH" npx --yes dsh-plugin-mobile-gateway@latest remove-helper
162
217
  | WebUI 没有“移动设备” | 确认安装在 `web` profile,并完整重启 `dsh web` |
163
218
  | iOS 收到 `503` | 回到 WebUI 开启“允许移动设备连接” |
164
219
  | iOS 收到 `401` | 在 WebUI 重新生成二维码并配对 |
165
- | 公网连接超时 | 检查云安全组、服务器防火墙和 TCP `80/443` |
166
- | 公网地址没有自动显示 | 确认已执行 `setup --ip <公网 IP>`,然后重启 `dsh web` |
220
+ | Linux 服务器公网连接超时 | 检查云安全组、服务器防火墙和 TCP `80/443` |
221
+ | Linux 服务器公网地址没有显示 | 在“移动设备 公网接入”填写公网 IPv4 并点击更新 |
167
222
  | 需要查看服务端日志 | 执行 `tail -f /tmp/mobile-gateway.log` |
168
223
 
169
224
  ## 源码开发
Binary file
package/lib/index.mjs CHANGED
@@ -33,6 +33,10 @@
33
33
  // view:"conversation" trims chunk/header events and tool output
34
34
  // { "type": "attachment", "sessionId", "attachmentId" }
35
35
  // -> verified image metadata + canonical base64 bytes
36
+ // { "type": "file-list", "requestId"?, "sessionId", "path"? }
37
+ // { "type": "file-download-open", "requestId", "sessionId", "path" }
38
+ // { "type": "file-download-read", "transferId", "offset" }
39
+ // { "type": "file-download-cancel", "transferId" }
36
40
  // { "type": "search", "query" } -> session search
37
41
  // { "type": "host" } -> host.describe snapshot (incl. default provider/model)
38
42
  // { "type": "default-model" } -> agentDefaultModel.currentSelection()
@@ -54,18 +58,30 @@
54
58
  // { "type": "set-default", "target": "agent-preset"|"permission", "value" }
55
59
  // { "type": "question-answer", "rpcId", "sessionId", "answers": [...] }
56
60
  // { "type": "question-cancel", "rpcId", "sessionId" }
57
- // server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images"],
61
+ // { "type": "approval-response", "rpcId", "sessionId", "approvalId",
62
+ // "outcome": "allowed-once"|"rejected" }
63
+ // server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images", "file-downloads"],
58
64
  // "authenticated", "device"?, "port", "clients" }
59
65
  // { "kind": "pong", "at" }
60
66
  // { "kind": "subscribed", "sessionId" }
61
67
  // { "kind": "sent", "sessionId", "mode", "command"? }
62
68
  // { "kind": "workspaces" | "sessions" | "history" | "search", ...data }
63
69
  // { "kind": "attachment", "sessionId", "attachment", "data" }
70
+ // { "kind": "file-list", "requestId"?, "sessionId", "path", "entries" }
71
+ // { "kind": "file-download-opened", "requestId", "transferId", "sessionId",
72
+ // "path", "name", "mediaType", "size", "chunkBytes" }
73
+ // { "kind": "file-download-chunk", "transferId", "offset", "data", "eof", "sha256"? }
74
+ // { "kind": "file-download-cancelled", "transferId" }
64
75
  // { "kind": "error", "code", "message", "requestType"?, "sessionId"? }
65
76
  // { "kind": "event", "sessionId", "seq", "time", "event": { ... } }
66
77
  // { "kind": "question-requested", "rpcId", "sessionId", "questions", "replay"? }
67
78
  // { "kind": "question-response", "rpcId", "sessionId", "action", "accepted", "reason"? }
68
79
  // { "kind": "question-resolved", "rpcId", "sessionId", "outcome" }
80
+ // { "kind": "approval-requested", "rpcId", "sessionId", "approvalId",
81
+ // "toolName", "callId"?, "reason"?, "replay"? }
82
+ // { "kind": "approval-response", "rpcId", "sessionId", "approvalId",
83
+ // "outcome", "accepted", "reason"? }
84
+ // { "kind": "approval-resolved", "rpcId", "sessionId", "approvalId", "outcome" }
69
85
  import fs from 'node:fs'
70
86
  import fsp from 'node:fs/promises'
71
87
  import http from 'node:http'
@@ -93,6 +109,11 @@ const HELPER_SOCKET = '/run/dsh-mobile-gateway/helper.sock'
93
109
  const DEFAULT_WS_PATH = '/ws/mobile'
94
110
  const DEFAULT_PAIRING_TTL_MS = 5 * 60 * 1000
95
111
  const DEFAULT_GATEWAY_WAIT_TIMEOUT_MS = 5 * 60 * 1000
112
+ const DEFAULT_FILE_DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024
113
+ const DEFAULT_FILE_DOWNLOAD_CHUNK_BYTES = 512 * 1024
114
+ const DEFAULT_FILE_DOWNLOAD_IDLE_MS = 2 * 60 * 1000
115
+ const DEFAULT_FILE_DOWNLOAD_MAX_TRANSFERS = 4
116
+ const INTERACTION_PROTOCOL_REVISION = 'question-approval-v2'
96
117
  // DSH 0.1.1 allows up to 100 MiB of decoded images in one prompt. Base64 plus
97
118
  // the JSON envelope needs roughly 4/3 of that on the wire. Keep this separately
98
119
  // configurable so deployments may choose a lower transport ceiling.
@@ -109,6 +130,11 @@ const Config = Schema.object({
109
130
  gatewayEnabled: Schema.boolean().default(false),
110
131
  gatewayWaitTimeoutMs: Schema.natural().min(30_000).max(30 * 60 * 1000).default(DEFAULT_GATEWAY_WAIT_TIMEOUT_MS),
111
132
  maxPayloadBytes: Schema.natural().min(1024 * 1024).max(160 * 1024 * 1024).default(DEFAULT_MAX_WS_PAYLOAD_BYTES),
133
+ fileDownloadsEnabled: Schema.boolean().default(true),
134
+ fileDownloadMaxBytes: Schema.natural().min(1024 * 1024).max(2 * 1024 * 1024 * 1024).default(DEFAULT_FILE_DOWNLOAD_MAX_BYTES),
135
+ fileDownloadChunkBytes: Schema.natural().min(64 * 1024).max(1024 * 1024).default(DEFAULT_FILE_DOWNLOAD_CHUNK_BYTES),
136
+ fileDownloadIdleMs: Schema.natural().min(10_000).max(30 * 60 * 1000).default(DEFAULT_FILE_DOWNLOAD_IDLE_MS),
137
+ fileDownloadMaxTransfers: Schema.natural().min(1).max(16).default(DEFAULT_FILE_DOWNLOAD_MAX_TRANSFERS),
112
138
  adminLoopbackOnly: Schema.boolean().default(true),
113
139
  publicUrl: Schema.string().default(''),
114
140
  deviceFile: Schema.string().default(''),
@@ -351,6 +377,320 @@ async function proxyQuery(api, type, method, payload, signal) {
351
377
  }
352
378
  }
353
379
 
380
+ function fileTransferError(code, message, requestType, sessionId) {
381
+ return {
382
+ kind: 'error',
383
+ code,
384
+ message,
385
+ requestType,
386
+ ...(sessionId ? { sessionId } : {}),
387
+ }
388
+ }
389
+
390
+ function fileMediaType(filePath) {
391
+ switch (path.extname(filePath).toLowerCase()) {
392
+ case '.apk': return 'application/vnd.android.package-archive'
393
+ case '.doc': return 'application/msword'
394
+ case '.docx': return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
395
+ case '.ipa': return 'application/octet-stream'
396
+ case '.json': return 'application/json'
397
+ case '.pdf': return 'application/pdf'
398
+ case '.ppt': return 'application/vnd.ms-powerpoint'
399
+ case '.pptx': return 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
400
+ case '.txt': return 'text/plain'
401
+ case '.xls': return 'application/vnd.ms-excel'
402
+ case '.xlsx': return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
403
+ case '.zip': return 'application/zip'
404
+ default: return 'application/octet-stream'
405
+ }
406
+ }
407
+
408
+ function workspaceRelativePath(root, target) {
409
+ const relative = path.relative(root, target)
410
+ return relative ? relative.split(path.sep).join('/') : '.'
411
+ }
412
+
413
+ function isInsideWorkspace(root, target) {
414
+ const relative = path.relative(root, target)
415
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))
416
+ }
417
+
418
+ function normalizedWorkspaceInput(value, { allowEmpty = false } = {}) {
419
+ if (value === undefined && allowEmpty) return '.'
420
+ if (typeof value !== 'string') return null
421
+ const trimmed = value.trim()
422
+ if (!trimmed && allowEmpty) return '.'
423
+ if (!trimmed || trimmed.includes('\0') || path.isAbsolute(trimmed)) return null
424
+ if (trimmed.split(/[\\/]+/).includes('..')) return null
425
+ return trimmed
426
+ }
427
+
428
+ // Owns only workspace-scoped, regular-file downloads. The host API has an
429
+ // image-only attachment read path, so arbitrary document/archive bytes must be
430
+ // guarded here instead of accepting a host path from the mobile client.
431
+ function createFileTransferManager(api, options) {
432
+ const transfers = new Map()
433
+
434
+ const closeTransfer = async (transferId) => {
435
+ const transfer = transfers.get(transferId)
436
+ if (!transfer) return false
437
+ transfers.delete(transferId)
438
+ try {
439
+ await transfer.handle.close()
440
+ } catch {
441
+ // A failed close cannot make a completed or cancelled transfer usable.
442
+ }
443
+ return true
444
+ }
445
+
446
+ const resolveSessionRoot = async (sessionId, requestType) => {
447
+ const sessions = await proxyQuery(api, requestType, api.sessions.list.bind(api.sessions), {})
448
+ if (sessions.kind === 'error') return sessions
449
+ const item = Array.isArray(sessions.items)
450
+ ? sessions.items.find((entry) => entry && String(entry.sessionId) === sessionId)
451
+ : null
452
+ if (!item) return fileTransferError('session-not-found', 'no such session', requestType, sessionId)
453
+ if (typeof item.cwd !== 'string' || !item.cwd.trim()) {
454
+ return fileTransferError('file-workspace-unavailable', 'the session has no working directory', requestType, sessionId)
455
+ }
456
+ try {
457
+ const root = await fsp.realpath(item.cwd)
458
+ const stat = await fsp.stat(root)
459
+ if (!stat.isDirectory()) {
460
+ return fileTransferError('file-workspace-unavailable', 'the session working directory is unavailable', requestType, sessionId)
461
+ }
462
+ return { root }
463
+ } catch {
464
+ return fileTransferError('file-workspace-unavailable', 'the session working directory is unavailable', requestType, sessionId)
465
+ }
466
+ }
467
+
468
+ const resolveWorkspaceTarget = async (sessionId, rawPath, requestType, inputOptions) => {
469
+ const requested = normalizedWorkspaceInput(rawPath, inputOptions)
470
+ if (!requested) {
471
+ return fileTransferError('bad-request', `${requestType} requires a relative workspace path`, requestType, sessionId)
472
+ }
473
+ const rootResult = await resolveSessionRoot(sessionId, requestType)
474
+ if (rootResult.kind === 'error') return rootResult
475
+ const candidate = path.resolve(rootResult.root, requested)
476
+ if (!isInsideWorkspace(rootResult.root, candidate)) {
477
+ return fileTransferError('file-not-allowed', 'path must stay inside the session working directory', requestType, sessionId)
478
+ }
479
+ try {
480
+ const target = await fsp.realpath(candidate)
481
+ if (!isInsideWorkspace(rootResult.root, target)) {
482
+ return fileTransferError('file-not-allowed', 'path must stay inside the session working directory', requestType, sessionId)
483
+ }
484
+ const stat = await fsp.stat(target)
485
+ return { root: rootResult.root, target, stat }
486
+ } catch (error) {
487
+ if (error && error.code === 'ENOENT') {
488
+ return fileTransferError('file-not-found', 'file does not exist', requestType, sessionId)
489
+ }
490
+ return fileTransferError('file-unreadable', 'file cannot be read', requestType, sessionId)
491
+ }
492
+ }
493
+
494
+ const fileList = async (msg) => {
495
+ const session = requireSessionId(msg)
496
+ if (session.error) return session.error
497
+ const resolved = await resolveWorkspaceTarget(session.value, msg.path, 'file-list', { allowEmpty: true })
498
+ if (resolved.kind === 'error') return resolved
499
+ if (!resolved.stat.isDirectory()) {
500
+ return fileTransferError('file-not-directory', 'path is not a directory', 'file-list', session.value)
501
+ }
502
+ try {
503
+ const dirents = await fsp.readdir(resolved.target, { withFileTypes: true })
504
+ const entries = []
505
+ for (const dirent of dirents) {
506
+ if (dirent.isSymbolicLink()) continue
507
+ const child = path.join(resolved.target, dirent.name)
508
+ if (dirent.isDirectory()) {
509
+ entries.push({ name: dirent.name, path: workspaceRelativePath(resolved.root, child), kind: 'directory' })
510
+ } else if (dirent.isFile()) {
511
+ const stat = await fsp.stat(child)
512
+ entries.push({
513
+ name: dirent.name,
514
+ path: workspaceRelativePath(resolved.root, child),
515
+ kind: 'file',
516
+ bytes: stat.size,
517
+ modifiedAt: stat.mtimeMs,
518
+ mediaType: fileMediaType(child),
519
+ })
520
+ }
521
+ }
522
+ entries.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'directory' ? -1 : 1))
523
+ const requestId = typeof msg.requestId === 'string' && msg.requestId.trim() ? msg.requestId.trim() : null
524
+ return {
525
+ kind: 'file-list',
526
+ ...(requestId ? { requestId } : {}),
527
+ sessionId: session.value,
528
+ path: workspaceRelativePath(resolved.root, resolved.target),
529
+ entries,
530
+ }
531
+ } catch {
532
+ return fileTransferError('file-unreadable', 'directory cannot be read', 'file-list', session.value)
533
+ }
534
+ }
535
+
536
+ const open = async (client, msg) => {
537
+ const requestId = typeof msg.requestId === 'string' && msg.requestId.trim() ? msg.requestId.trim() : null
538
+ const session = requireSessionId(msg)
539
+ if (!requestId) return fileTransferError('bad-request', 'file-download-open requires a requestId', 'file-download-open')
540
+ if (session.error) return session.error
541
+ if (transfers.size >= options.fileDownloadMaxTransfers) {
542
+ return fileTransferError('file-transfer-limit', 'too many active file downloads', 'file-download-open', session.value)
543
+ }
544
+ const resolved = await resolveWorkspaceTarget(session.value, msg.path, 'file-download-open')
545
+ if (resolved.kind === 'error') return resolved
546
+ if (!resolved.stat.isFile()) {
547
+ return fileTransferError('file-not-regular', 'only regular files can be downloaded', 'file-download-open', session.value)
548
+ }
549
+ if (resolved.stat.size > options.fileDownloadMaxBytes) {
550
+ return fileTransferError('file-too-large', 'file exceeds the configured download limit', 'file-download-open', session.value)
551
+ }
552
+
553
+ let handle
554
+ try {
555
+ // Reject a last-moment leaf symlink replacement after the realpath
556
+ // containment check above. macOS and Linux both support O_NOFOLLOW.
557
+ handle = await fsp.open(resolved.target, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
558
+ const opened = await handle.stat()
559
+ if (!opened.isFile() || opened.size !== resolved.stat.size) {
560
+ await handle.close()
561
+ return fileTransferError('file-changed', 'file changed before download could start', 'file-download-open', session.value)
562
+ }
563
+ } catch {
564
+ return fileTransferError('file-unreadable', 'file cannot be opened', 'file-download-open', session.value)
565
+ }
566
+
567
+ const transferId = crypto.randomUUID()
568
+ const transfer = {
569
+ client,
570
+ handle,
571
+ sessionId: session.value,
572
+ path: workspaceRelativePath(resolved.root, resolved.target),
573
+ name: path.basename(resolved.target),
574
+ mediaType: fileMediaType(resolved.target),
575
+ size: resolved.stat.size,
576
+ offset: 0,
577
+ hash: crypto.createHash('sha256'),
578
+ reading: false,
579
+ lastActiveAt: Date.now(),
580
+ }
581
+ transfers.set(transferId, transfer)
582
+ log(`file download opened: transfer=${transferId} session=${transfer.sessionId} bytes=${transfer.size}`)
583
+ return {
584
+ kind: 'file-download-opened',
585
+ requestId,
586
+ transferId,
587
+ sessionId: transfer.sessionId,
588
+ path: transfer.path,
589
+ name: transfer.name,
590
+ mediaType: transfer.mediaType,
591
+ size: transfer.size,
592
+ chunkBytes: options.fileDownloadChunkBytes,
593
+ }
594
+ }
595
+
596
+ const read = async (client, msg) => {
597
+ const transferId = typeof msg.transferId === 'string' && msg.transferId.trim() ? msg.transferId.trim() : null
598
+ if (!transferId) return fileTransferError('bad-request', 'file-download-read requires a transferId', 'file-download-read')
599
+ const transfer = transfers.get(transferId)
600
+ if (!transfer || transfer.client !== client) {
601
+ return fileTransferError('file-transfer-not-found', 'file download is no longer active', 'file-download-read')
602
+ }
603
+ if (!Number.isSafeInteger(msg.offset) || msg.offset !== transfer.offset) {
604
+ return fileTransferError('file-transfer-offset', 'offset must match the next unread byte', 'file-download-read', transfer.sessionId)
605
+ }
606
+ if (transfer.reading) {
607
+ return fileTransferError('file-transfer-busy', 'a chunk read is already in progress', 'file-download-read', transfer.sessionId)
608
+ }
609
+
610
+ transfer.reading = true
611
+ try {
612
+ const remaining = transfer.size - transfer.offset
613
+ const bytesToRead = Math.min(options.fileDownloadChunkBytes, remaining)
614
+ const chunk = Buffer.allocUnsafe(bytesToRead)
615
+ const { bytesRead } = bytesToRead === 0
616
+ ? { bytesRead: 0 }
617
+ : await transfer.handle.read(chunk, 0, bytesToRead, transfer.offset)
618
+ if (bytesRead !== bytesToRead) {
619
+ await closeTransfer(transferId)
620
+ return fileTransferError('file-changed', 'file changed during download', 'file-download-read', transfer.sessionId)
621
+ }
622
+ const payload = bytesRead === chunk.length ? chunk : chunk.subarray(0, bytesRead)
623
+ transfer.hash.update(payload)
624
+ const offset = transfer.offset
625
+ transfer.offset += bytesRead
626
+ transfer.lastActiveAt = Date.now()
627
+ const eof = transfer.offset === transfer.size
628
+ const frame = {
629
+ kind: 'file-download-chunk',
630
+ transferId,
631
+ offset,
632
+ data: payload.toString('base64'),
633
+ eof,
634
+ }
635
+ if (eof) {
636
+ frame.sha256 = transfer.hash.digest('hex')
637
+ await closeTransfer(transferId)
638
+ log(`file download completed: transfer=${transferId} bytes=${transfer.size}`)
639
+ }
640
+ return frame
641
+ } catch {
642
+ await closeTransfer(transferId)
643
+ return fileTransferError('file-unreadable', 'file cannot be read', 'file-download-read', transfer.sessionId)
644
+ } finally {
645
+ transfer.reading = false
646
+ }
647
+ }
648
+
649
+ const cancel = async (client, msg) => {
650
+ const transferId = typeof msg.transferId === 'string' && msg.transferId.trim() ? msg.transferId.trim() : null
651
+ if (!transferId) return fileTransferError('bad-request', 'file-download-cancel requires a transferId', 'file-download-cancel')
652
+ const transfer = transfers.get(transferId)
653
+ if (!transfer || transfer.client !== client) {
654
+ return fileTransferError('file-transfer-not-found', 'file download is no longer active', 'file-download-cancel')
655
+ }
656
+ await closeTransfer(transferId)
657
+ log(`file download cancelled: transfer=${transferId}`)
658
+ return { kind: 'file-download-cancelled', transferId }
659
+ }
660
+
661
+ const expire = async () => {
662
+ const cutoff = Date.now() - options.fileDownloadIdleMs
663
+ for (const [transferId, transfer] of transfers) {
664
+ if (transfer.lastActiveAt < cutoff && !transfer.reading) {
665
+ await closeTransfer(transferId)
666
+ log(`file download expired: transfer=${transferId}`)
667
+ }
668
+ }
669
+ }
670
+
671
+ return {
672
+ async handle(client, msg) {
673
+ if (!options.fileDownloadsEnabled) {
674
+ return fileTransferError('file-download-disabled', 'file downloads are disabled by gateway configuration', msg.type)
675
+ }
676
+ if (msg.type === 'file-list') return fileList(msg)
677
+ if (msg.type === 'file-download-open') return open(client, msg)
678
+ if (msg.type === 'file-download-read') return read(client, msg)
679
+ if (msg.type === 'file-download-cancel') return cancel(client, msg)
680
+ return null
681
+ },
682
+ async closeClient(client) {
683
+ for (const [transferId, transfer] of transfers) {
684
+ if (transfer.client === client) await closeTransfer(transferId)
685
+ }
686
+ },
687
+ async dispose() {
688
+ for (const transferId of [...transfers.keys()]) await closeTransfer(transferId)
689
+ },
690
+ expire,
691
+ }
692
+ }
693
+
354
694
  // List one directory level directly with node:fs, mirroring the official
355
695
  // browse backend's semantics (crumbs + name-sorted child directories). Works
356
696
  // on every deployment regardless of the composed picker capability, which
@@ -689,7 +1029,9 @@ async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
689
1029
  const execution = await typertGateway.invoke({
690
1030
  namespace: 'commands',
691
1031
  method: 'execute',
692
- args: { agentId: sessionId.value, line: '/permission ' + name },
1032
+ // commands/execute 的当前 Typert descriptor 要求 images 字段始终存在;
1033
+ // 权限斜杠命令没有附件,因此显式传空数组。
1034
+ args: { agentId: sessionId.value, line: '/permission ' + name, images: [] },
693
1035
  signal: new AbortController().signal,
694
1036
  })
695
1037
  if (execution === undefined || execution === null) {
@@ -1013,6 +1355,11 @@ const plugin = {
1013
1355
  gatewayEnabled: false,
1014
1356
  gatewayWaitTimeoutMs: DEFAULT_GATEWAY_WAIT_TIMEOUT_MS,
1015
1357
  maxPayloadBytes: DEFAULT_MAX_WS_PAYLOAD_BYTES,
1358
+ fileDownloadsEnabled: true,
1359
+ fileDownloadMaxBytes: DEFAULT_FILE_DOWNLOAD_MAX_BYTES,
1360
+ fileDownloadChunkBytes: DEFAULT_FILE_DOWNLOAD_CHUNK_BYTES,
1361
+ fileDownloadIdleMs: DEFAULT_FILE_DOWNLOAD_IDLE_MS,
1362
+ fileDownloadMaxTransfers: DEFAULT_FILE_DOWNLOAD_MAX_TRANSFERS,
1016
1363
  adminLoopbackOnly: true,
1017
1364
  publicUrl: '',
1018
1365
  deviceFile: '',
@@ -1050,7 +1397,14 @@ const plugin = {
1050
1397
  })
1051
1398
  const clients = new Set()
1052
1399
  const pendingQuestions = new Map()
1053
- const questionMuxAbort = new AbortController()
1400
+ const pendingApprovals = new Map()
1401
+ const fileTransfers = createFileTransferManager(api, options)
1402
+ const fileTransferExpiryTimer = setInterval(() => {
1403
+ fileTransfers.expire().catch((error) => {
1404
+ log(`file download expiry failed: ${error && error.message ? error.message : String(error)}`)
1405
+ })
1406
+ }, Math.min(options.fileDownloadIdleMs, 30_000))
1407
+ const interactionMuxAbort = new AbortController()
1054
1408
  let counter = 0
1055
1409
  let gatewayEnabled = options.gatewayEnabled === true
1056
1410
  let waitExpiresAt = null
@@ -1120,7 +1474,18 @@ const plugin = {
1120
1474
  ...(replay ? { replay: true } : {}),
1121
1475
  })
1122
1476
 
1123
- const broadcastQuestionFrame = (frame) => {
1477
+ const approvalFrameFor = (rpcId, payload, replay = false) => ({
1478
+ kind: 'approval-requested',
1479
+ rpcId: String(rpcId),
1480
+ sessionId: String(payload.sessionId),
1481
+ approvalId: String(payload.approvalId),
1482
+ toolName: payload.toolName,
1483
+ ...(payload.callId !== undefined ? { callId: payload.callId } : {}),
1484
+ ...(payload.reason !== undefined ? { reason: payload.reason } : {}),
1485
+ ...(replay ? { replay: true } : {}),
1486
+ })
1487
+
1488
+ const broadcastInteractionFrame = (frame) => {
1124
1489
  const wire = JSON.stringify(frame)
1125
1490
  for (const client of clients) {
1126
1491
  if (client.filterSessionId && client.filterSessionId !== frame.sessionId) continue
@@ -1128,6 +1493,23 @@ const plugin = {
1128
1493
  }
1129
1494
  }
1130
1495
 
1496
+ const replayPendingInteractions = (ws, trigger) => {
1497
+ let questionCount = 0
1498
+ let approvalCount = 0
1499
+ for (const pending of pendingQuestions.values()) {
1500
+ if (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
1501
+ ws.send(JSON.stringify(questionFrameFor(pending.rpcId, pending, true)))
1502
+ questionCount += 1
1503
+ }
1504
+ for (const pending of pendingApprovals.values()) {
1505
+ if (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
1506
+ ws.send(JSON.stringify(approvalFrameFor(pending.rpcId, pending, true)))
1507
+ approvalCount += 1
1508
+ }
1509
+ log(`interaction replay: trigger=${trigger} filtered=${Boolean(ws.filterSessionId)} questions=${questionCount} approvals=${approvalCount}`)
1510
+ return { questionCount, approvalCount }
1511
+ }
1512
+
1131
1513
  const respondToQuestion = async (msg, cancel = false) => {
1132
1514
  const rpcId = typeof msg.rpcId === 'string' && msg.rpcId.trim() ? msg.rpcId.trim() : null
1133
1515
  const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() ? msg.sessionId.trim() : null
@@ -1178,9 +1560,54 @@ const plugin = {
1178
1560
  }
1179
1561
  }
1180
1562
 
1181
- if (gatewayEnabled) setGatewayEnabled(true, 'enabled by startup config')
1563
+ const respondToApproval = async (msg) => {
1564
+ const rpcId = typeof msg.rpcId === 'string' && msg.rpcId.trim() ? msg.rpcId.trim() : null
1565
+ const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() ? msg.sessionId.trim() : null
1566
+ const approvalId = typeof msg.approvalId === 'string' && msg.approvalId.trim() ? msg.approvalId.trim() : null
1567
+ const outcome = msg.outcome
1568
+ if (!rpcId || !sessionId || !approvalId) {
1569
+ return { kind: 'error', code: 'bad-request', message: 'approval-response requires rpcId, sessionId, and approvalId', requestType: msg.type }
1570
+ }
1571
+ if (outcome !== 'allowed-once' && outcome !== 'rejected') {
1572
+ return { kind: 'error', code: 'bad-request', message: 'approval-response outcome must be "allowed-once" or "rejected"', requestType: msg.type, sessionId }
1573
+ }
1182
1574
 
1183
- log(`applying: path=${wsPath}, webServer.port=${webServer.port}, gatewayEnabled=${gatewayEnabled}, requireAuth=${requireAuth}, devices=${registry.count()}`)
1575
+ const pending = pendingApprovals.get(rpcId)
1576
+ if (pending && (pending.sessionId !== sessionId || pending.approvalId !== approvalId)) {
1577
+ return { kind: 'error', code: 'bad-request', message: 'approval-response does not match the pending approval', requestType: msg.type, sessionId }
1578
+ }
1579
+
1580
+ try {
1581
+ const receipt = await api.respond({
1582
+ type: 'client-response',
1583
+ rpcId,
1584
+ result: { ok: true, value: { sessionId, approvalId, outcome } },
1585
+ })
1586
+ log(`approval response: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} outcome=${outcome} accepted=${receipt.accepted}${receipt.accepted ? '' : ` reason=${receipt.reason}`}`)
1587
+ return {
1588
+ kind: 'approval-response',
1589
+ rpcId,
1590
+ sessionId,
1591
+ approvalId,
1592
+ outcome,
1593
+ accepted: receipt.accepted,
1594
+ ...(!receipt.accepted ? { reason: receipt.reason } : {}),
1595
+ }
1596
+ } catch (error) {
1597
+ const message = error && error.message ? error.message : String(error)
1598
+ log(`approval response failed: rpcId=${rpcId} ${message}`)
1599
+ return { kind: 'error', code: 'internal', message, requestType: msg.type, sessionId }
1600
+ }
1601
+ }
1602
+
1603
+ // Startup-config enablement (gatewayEnabled: true in cordis.patch.yml) is a
1604
+ // standing-service decision made by the operator: do NOT arm the no-device
1605
+ // auto-close. That safety net stays attached to the ephemeral panel toggle,
1606
+ // where it belongs. gatewayEnabled is already true from options at this point,
1607
+ // so nothing else is needed here beyond the log line.
1608
+ if (gatewayEnabled) log('mobile gateway enabled by startup config: standing mode, no auto-close timer')
1609
+
1610
+ log(`applying: version=${PLUGIN_VERSION} interactionProtocol=${INTERACTION_PROTOCOL_REVISION} path=${wsPath}, webServer.port=${webServer.port}, gatewayEnabled=${gatewayEnabled}, requireAuth=${requireAuth}, devices=${registry.count()}`)
1184
1611
 
1185
1612
  // ---- device management routes (loopback-gated admin surface) ----
1186
1613
  const disposeMgmt = webServer.register({
@@ -1374,6 +1801,7 @@ const plugin = {
1374
1801
  } else if (msg.type === 'subscribe') {
1375
1802
  ws.filterSessionId = typeof msg.sessionId === 'string' ? msg.sessionId : undefined
1376
1803
  ws.send(JSON.stringify({ kind: 'subscribed', sessionId: ws.filterSessionId || null }))
1804
+ replayPendingInteractions(ws, 'subscribe')
1377
1805
  } else if (msg.type === 'unsubscribe') {
1378
1806
  ws.filterSessionId = undefined
1379
1807
  ws.send(JSON.stringify({ kind: 'subscribed', sessionId: null }))
@@ -1381,6 +1809,15 @@ const plugin = {
1381
1809
  respondToQuestion(msg, msg.type === 'question-cancel').then((frame) => {
1382
1810
  if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
1383
1811
  })
1812
+ } else if (msg.type === 'approval-response') {
1813
+ respondToApproval(msg).then((frame) => {
1814
+ if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
1815
+ })
1816
+ } else if (msg.type === 'file-list' || msg.type === 'file-download-open' ||
1817
+ msg.type === 'file-download-read' || msg.type === 'file-download-cancel') {
1818
+ fileTransfers.handle(ws, msg).then((frame) => {
1819
+ if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
1820
+ })
1384
1821
  } else if (msg.type === 'message') {
1385
1822
  admitMessage(api, msg).then((frame) => {
1386
1823
  if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
@@ -1403,6 +1840,9 @@ const plugin = {
1403
1840
  ws.on('close', () => {
1404
1841
  clients.delete(ws)
1405
1842
  if (ws.deviceId) registry.disconnected(ws.deviceId)
1843
+ fileTransfers.closeClient(ws).catch((error) => {
1844
+ log(`file download cleanup failed: ${error && error.message ? error.message : String(error)}`)
1845
+ })
1406
1846
  log(`client disconnected (id=${id}, remaining=${clients.size})`)
1407
1847
  })
1408
1848
 
@@ -1417,16 +1857,13 @@ const plugin = {
1417
1857
  ws.send(JSON.stringify({
1418
1858
  kind: 'hello',
1419
1859
  protocol: 3,
1420
- capabilities: ['images'],
1860
+ capabilities: ['images', ...(options.fileDownloadsEnabled ? ['file-downloads'] : [])],
1421
1861
  port: transport.port || webServer.port,
1422
1862
  clients: clients.size,
1423
1863
  authenticated: !!device,
1424
1864
  ...(device ? { device: { id: device.id, name: device.name } } : {}),
1425
1865
  }))
1426
- for (const pending of pendingQuestions.values()) {
1427
- if (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
1428
- ws.send(JSON.stringify(questionFrameFor(pending.rpcId, pending, true)))
1429
- }
1866
+ replayPendingInteractions(ws, 'connect')
1430
1867
  })
1431
1868
  }
1432
1869
 
@@ -1491,15 +1928,16 @@ const plugin = {
1491
1928
  })
1492
1929
  log('session/event listener attached')
1493
1930
 
1494
- const questionMuxTask = api.events && typeof api.events.mux === 'function' && typeof api.respond === 'function'
1931
+ const interactionMuxTask = api.events && typeof api.events.mux === 'function' && typeof api.respond === 'function'
1495
1932
  ? (async () => {
1496
1933
  try {
1497
1934
  for await (const envelope of api.events.mux(
1498
1935
  { rpcId: crypto.randomUUID(), payload: {} },
1499
- questionMuxAbort.signal,
1936
+ interactionMuxAbort.signal,
1500
1937
  )) {
1501
1938
  const payload = envelope && envelope.payload
1502
1939
  if (!payload || typeof payload.type !== 'string') continue
1940
+ log(`interaction observed: type=${payload.type} hasRpc=${envelope.rpcId !== undefined} hasSession=${payload.sessionId !== undefined} hasApprovalId=${payload.approvalId !== undefined} hasToolName=${payload.toolName !== undefined}`)
1503
1941
  if (payload.type === 'question/requested') {
1504
1942
  const rpcId = String(envelope.rpcId)
1505
1943
  const pending = {
@@ -1508,34 +1946,72 @@ const plugin = {
1508
1946
  questions: payload.questions,
1509
1947
  }
1510
1948
  pendingQuestions.set(rpcId, pending)
1511
- broadcastQuestionFrame(questionFrameFor(rpcId, pending))
1949
+ broadcastInteractionFrame(questionFrameFor(rpcId, pending))
1512
1950
  log(`question requested: rpcId=${rpcId} session=${pending.sessionId} questions=${Array.isArray(pending.questions) ? pending.questions.length : 0}`)
1951
+ } else if (payload.type === 'approval/requested') {
1952
+ const rpcId = String(envelope.rpcId)
1953
+ const pending = {
1954
+ rpcId,
1955
+ sessionId: String(payload.sessionId),
1956
+ approvalId: String(payload.approvalId),
1957
+ toolName: payload.toolName,
1958
+ ...(payload.callId !== undefined ? { callId: payload.callId } : {}),
1959
+ ...(payload.reason !== undefined ? { reason: payload.reason } : {}),
1960
+ }
1961
+ pendingApprovals.set(rpcId, pending)
1962
+ broadcastInteractionFrame(approvalFrameFor(rpcId, pending))
1963
+ log(`approval requested: rpcId=${rpcId} approvalId=${pending.approvalId} session=${pending.sessionId} tool=${pending.toolName}`)
1513
1964
  } else if (payload.type === 'question/resolved') {
1514
1965
  const questionRpcId = String(payload.questionRpcId)
1515
1966
  pendingQuestions.delete(questionRpcId)
1516
- broadcastQuestionFrame({
1967
+ broadcastInteractionFrame({
1517
1968
  kind: 'question-resolved',
1518
1969
  rpcId: questionRpcId,
1519
1970
  sessionId: String(payload.sessionId),
1520
1971
  outcome: payload.outcome,
1521
1972
  })
1522
1973
  log(`question resolved: rpcId=${questionRpcId} session=${payload.sessionId} outcome=${payload.outcome}`)
1974
+ } else if (payload.type === 'approval/resolved') {
1975
+ const approvalId = String(payload.approvalId)
1976
+ const sessionId = String(payload.sessionId)
1977
+ const entry = [...pendingApprovals.entries()].find(([, pending]) => pending.approvalId === approvalId && pending.sessionId === sessionId)
1978
+ if (!entry) {
1979
+ log(`approval resolved without pending request: approvalId=${approvalId} session=${sessionId} outcome=${payload.outcome}`)
1980
+ continue
1981
+ }
1982
+ const [rpcId, pending] = entry
1983
+ pendingApprovals.delete(rpcId)
1984
+ broadcastInteractionFrame({
1985
+ kind: 'approval-resolved',
1986
+ rpcId,
1987
+ sessionId,
1988
+ approvalId,
1989
+ outcome: payload.outcome,
1990
+ })
1991
+ log(`approval resolved: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} outcome=${payload.outcome}`)
1992
+ } else {
1993
+ log(`interaction ignored: type=${payload.type}`)
1523
1994
  }
1524
1995
  }
1525
1996
  } catch (error) {
1526
- if (!questionMuxAbort.signal.aborted) {
1527
- log(`question mux failed: ${error && error.message ? error.message : String(error)}`)
1997
+ if (!interactionMuxAbort.signal.aborted) {
1998
+ log(`interaction mux failed: ${error && error.message ? error.message : String(error)}`)
1528
1999
  }
1529
2000
  }
1530
2001
  })()
1531
2002
  : null
1532
- if (questionMuxTask) log('api gateway question mux attached')
1533
- else log('api gateway question mux unavailable; human-in-the-loop disabled')
2003
+ if (interactionMuxTask) log('api gateway interaction mux attached')
2004
+ else log('api gateway interaction mux unavailable; human-in-the-loop disabled')
1534
2005
 
1535
2006
  ctx.effect(() => () => {
1536
2007
  if (waitTimer) clearTimeout(waitTimer)
1537
- questionMuxAbort.abort()
2008
+ clearInterval(fileTransferExpiryTimer)
2009
+ interactionMuxAbort.abort()
1538
2010
  pendingQuestions.clear()
2011
+ pendingApprovals.clear()
2012
+ fileTransfers.dispose().catch((error) => {
2013
+ log(`file download disposal failed: ${error && error.message ? error.message : String(error)}`)
2014
+ })
1539
2015
  disposeUpgrade()
1540
2016
  disposeMgmt()
1541
2017
  disposeEvents()
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-gateway",
3
- "version": "0.6.4",
4
- "description": "update readme",
3
+ "version": "0.6.8",
4
+ "description": "1.support permission requirement;\n2. support file transfer",
5
5
  "main": "lib/index.mjs",
6
6
  "files": [
7
7
  "bin",