dsh-plugin-mobile-gateway 0.6.5 → 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.
Files changed (3) hide show
  1. package/PROTOCOL.md +140 -11
  2. package/lib/index.mjs +487 -18
  3. package/package.json +2 -2
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/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
@@ -1015,6 +1355,11 @@ const plugin = {
1015
1355
  gatewayEnabled: false,
1016
1356
  gatewayWaitTimeoutMs: DEFAULT_GATEWAY_WAIT_TIMEOUT_MS,
1017
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,
1018
1363
  adminLoopbackOnly: true,
1019
1364
  publicUrl: '',
1020
1365
  deviceFile: '',
@@ -1052,7 +1397,14 @@ const plugin = {
1052
1397
  })
1053
1398
  const clients = new Set()
1054
1399
  const pendingQuestions = new Map()
1055
- 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()
1056
1408
  let counter = 0
1057
1409
  let gatewayEnabled = options.gatewayEnabled === true
1058
1410
  let waitExpiresAt = null
@@ -1122,7 +1474,18 @@ const plugin = {
1122
1474
  ...(replay ? { replay: true } : {}),
1123
1475
  })
1124
1476
 
1125
- 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) => {
1126
1489
  const wire = JSON.stringify(frame)
1127
1490
  for (const client of clients) {
1128
1491
  if (client.filterSessionId && client.filterSessionId !== frame.sessionId) continue
@@ -1130,6 +1493,23 @@ const plugin = {
1130
1493
  }
1131
1494
  }
1132
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
+
1133
1513
  const respondToQuestion = async (msg, cancel = false) => {
1134
1514
  const rpcId = typeof msg.rpcId === 'string' && msg.rpcId.trim() ? msg.rpcId.trim() : null
1135
1515
  const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() ? msg.sessionId.trim() : null
@@ -1180,6 +1560,46 @@ const plugin = {
1180
1560
  }
1181
1561
  }
1182
1562
 
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
+ }
1574
+
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
+
1183
1603
  // Startup-config enablement (gatewayEnabled: true in cordis.patch.yml) is a
1184
1604
  // standing-service decision made by the operator: do NOT arm the no-device
1185
1605
  // auto-close. That safety net stays attached to the ephemeral panel toggle,
@@ -1187,7 +1607,7 @@ const plugin = {
1187
1607
  // so nothing else is needed here beyond the log line.
1188
1608
  if (gatewayEnabled) log('mobile gateway enabled by startup config: standing mode, no auto-close timer')
1189
1609
 
1190
- log(`applying: path=${wsPath}, webServer.port=${webServer.port}, gatewayEnabled=${gatewayEnabled}, requireAuth=${requireAuth}, devices=${registry.count()}`)
1610
+ log(`applying: version=${PLUGIN_VERSION} interactionProtocol=${INTERACTION_PROTOCOL_REVISION} path=${wsPath}, webServer.port=${webServer.port}, gatewayEnabled=${gatewayEnabled}, requireAuth=${requireAuth}, devices=${registry.count()}`)
1191
1611
 
1192
1612
  // ---- device management routes (loopback-gated admin surface) ----
1193
1613
  const disposeMgmt = webServer.register({
@@ -1381,6 +1801,7 @@ const plugin = {
1381
1801
  } else if (msg.type === 'subscribe') {
1382
1802
  ws.filterSessionId = typeof msg.sessionId === 'string' ? msg.sessionId : undefined
1383
1803
  ws.send(JSON.stringify({ kind: 'subscribed', sessionId: ws.filterSessionId || null }))
1804
+ replayPendingInteractions(ws, 'subscribe')
1384
1805
  } else if (msg.type === 'unsubscribe') {
1385
1806
  ws.filterSessionId = undefined
1386
1807
  ws.send(JSON.stringify({ kind: 'subscribed', sessionId: null }))
@@ -1388,6 +1809,15 @@ const plugin = {
1388
1809
  respondToQuestion(msg, msg.type === 'question-cancel').then((frame) => {
1389
1810
  if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
1390
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
+ })
1391
1821
  } else if (msg.type === 'message') {
1392
1822
  admitMessage(api, msg).then((frame) => {
1393
1823
  if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
@@ -1410,6 +1840,9 @@ const plugin = {
1410
1840
  ws.on('close', () => {
1411
1841
  clients.delete(ws)
1412
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
+ })
1413
1846
  log(`client disconnected (id=${id}, remaining=${clients.size})`)
1414
1847
  })
1415
1848
 
@@ -1424,16 +1857,13 @@ const plugin = {
1424
1857
  ws.send(JSON.stringify({
1425
1858
  kind: 'hello',
1426
1859
  protocol: 3,
1427
- capabilities: ['images'],
1860
+ capabilities: ['images', ...(options.fileDownloadsEnabled ? ['file-downloads'] : [])],
1428
1861
  port: transport.port || webServer.port,
1429
1862
  clients: clients.size,
1430
1863
  authenticated: !!device,
1431
1864
  ...(device ? { device: { id: device.id, name: device.name } } : {}),
1432
1865
  }))
1433
- for (const pending of pendingQuestions.values()) {
1434
- if (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
1435
- ws.send(JSON.stringify(questionFrameFor(pending.rpcId, pending, true)))
1436
- }
1866
+ replayPendingInteractions(ws, 'connect')
1437
1867
  })
1438
1868
  }
1439
1869
 
@@ -1498,15 +1928,16 @@ const plugin = {
1498
1928
  })
1499
1929
  log('session/event listener attached')
1500
1930
 
1501
- 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'
1502
1932
  ? (async () => {
1503
1933
  try {
1504
1934
  for await (const envelope of api.events.mux(
1505
1935
  { rpcId: crypto.randomUUID(), payload: {} },
1506
- questionMuxAbort.signal,
1936
+ interactionMuxAbort.signal,
1507
1937
  )) {
1508
1938
  const payload = envelope && envelope.payload
1509
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}`)
1510
1941
  if (payload.type === 'question/requested') {
1511
1942
  const rpcId = String(envelope.rpcId)
1512
1943
  const pending = {
@@ -1515,34 +1946,72 @@ const plugin = {
1515
1946
  questions: payload.questions,
1516
1947
  }
1517
1948
  pendingQuestions.set(rpcId, pending)
1518
- broadcastQuestionFrame(questionFrameFor(rpcId, pending))
1949
+ broadcastInteractionFrame(questionFrameFor(rpcId, pending))
1519
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}`)
1520
1964
  } else if (payload.type === 'question/resolved') {
1521
1965
  const questionRpcId = String(payload.questionRpcId)
1522
1966
  pendingQuestions.delete(questionRpcId)
1523
- broadcastQuestionFrame({
1967
+ broadcastInteractionFrame({
1524
1968
  kind: 'question-resolved',
1525
1969
  rpcId: questionRpcId,
1526
1970
  sessionId: String(payload.sessionId),
1527
1971
  outcome: payload.outcome,
1528
1972
  })
1529
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}`)
1530
1994
  }
1531
1995
  }
1532
1996
  } catch (error) {
1533
- if (!questionMuxAbort.signal.aborted) {
1534
- 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)}`)
1535
1999
  }
1536
2000
  }
1537
2001
  })()
1538
2002
  : null
1539
- if (questionMuxTask) log('api gateway question mux attached')
1540
- 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')
1541
2005
 
1542
2006
  ctx.effect(() => () => {
1543
2007
  if (waitTimer) clearTimeout(waitTimer)
1544
- questionMuxAbort.abort()
2008
+ clearInterval(fileTransferExpiryTimer)
2009
+ interactionMuxAbort.abort()
1545
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
+ })
1546
2015
  disposeUpgrade()
1547
2016
  disposeMgmt()
1548
2017
  disposeEvents()
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-gateway",
3
- "version": "0.6.5",
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",