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.
- package/PROTOCOL.md +140 -11
- package/lib/index.mjs +487 -18
- 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
|
|
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
|
-
|
|
167
|
+
### 3.1 提问与回答
|
|
157
168
|
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
1534
|
-
log(`
|
|
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 (
|
|
1540
|
-
else log('api gateway
|
|
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
|
-
|
|
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