dsh-plugin-mobile-gateway 0.6.4 → 0.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PROTOCOL.md +140 -11
- package/README.md +80 -25
- package/docs/assets/public-access-ui.png +0 -0
- package/lib/index.mjs +496 -20
- 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/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
- WebSocket:`/ws/mobile`
|
|
10
10
|
- 局域网:`ws://<局域网 IP>:3081/ws/mobile`
|
|
11
|
-
-
|
|
11
|
+
- Linux 服务器公网:`wss://<公网 IP>/ws/mobile`
|
|
12
12
|
- 协议文档:[PROTOCOL.md](PROTOCOL.md)
|
|
13
13
|
|
|
14
14
|
## 配套 iOS 客户端
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
## 安装插件
|
|
32
32
|
|
|
33
|
-
前提:已经安装 `dsh` CLI
|
|
33
|
+
前提:已经安装 `dsh` CLI 和 `pnpm`,并能正常启动 `dsh web`。可先执行 `pnpm --version` 确认当前用户的环境能够找到 `pnpm`。
|
|
34
34
|
|
|
35
35
|
局域网使用只需安装插件:
|
|
36
36
|
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
dsh plugin --profile web add dsh-plugin-mobile-gateway@latest
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
需要在 Linux 服务器通过公网 IP 接入时,推荐执行统一初始化命令。它会安装/更新插件,并请求一次 sudo 权限安装系统 Helper:
|
|
42
42
|
|
|
43
43
|
```bash
|
|
44
44
|
npx --yes dsh-plugin-mobile-gateway@latest init
|
|
@@ -68,35 +68,41 @@ dsh web
|
|
|
68
68
|
|
|
69
69
|
如果系统防火墙拦截连接,只允许私有网络访问 TCP `3081`。不要把 3081 开放到公网。
|
|
70
70
|
|
|
71
|
-
##
|
|
71
|
+
## Linux 服务器公网 IP 配对
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
> [!NOTE]
|
|
74
|
+
> 本文所说的“公网 IP 配对”特指 Linux 服务器。一键公网安装从 `v0.6.4` 开始提供,适用于带固定公网 IPv4 的 Ubuntu/Debian 服务器。服务器需要已经安装 Node.js、`pnpm` 和 `dsh` CLI;当前尚不支持 CentOS。
|
|
74
75
|
|
|
75
|
-
### 1.
|
|
76
|
+
### 1. 准备公网端口
|
|
76
77
|
|
|
77
|
-
|
|
78
|
+
在云厂商控制台复制服务器的公网 IPv4,并在安全组中放行入站 TCP `80` 和 `443`。不要将 DSH WebUI 端口或 TCP `3081` 开放到公网。
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
不要开放 TCP `3080` 和 `3081`!!!
|
|
81
|
-
不要开放 TCP `3080` 和 `3081`!!!
|
|
80
|
+
### 2. 一键初始化
|
|
82
81
|
|
|
83
|
-
|
|
82
|
+
先确认普通 DSH 用户可以直接调用 `pnpm`:
|
|
84
83
|
|
|
85
|
-
|
|
84
|
+
```bash
|
|
85
|
+
pnpm --version
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
然后使用同一个普通用户执行(不要使用 `root` 或 `sudo npx`):
|
|
86
89
|
|
|
87
90
|
```bash
|
|
91
|
+
npm_config_registry=https://registry.npmjs.org \
|
|
88
92
|
npx --yes dsh-plugin-mobile-gateway@latest init
|
|
89
93
|
```
|
|
90
94
|
|
|
91
|
-
|
|
95
|
+
该命令会安装或更新插件,并请求一次 sudo 权限安装 Nginx、Certbot、系统 Helper 和证书续期定时器。`init` 会确保 DSH profile 与 Helper 使用同一个精确版本。
|
|
96
|
+
|
|
97
|
+
完成后启动或重新启动 WebUI:
|
|
92
98
|
|
|
93
99
|
```bash
|
|
94
100
|
dsh web
|
|
95
101
|
```
|
|
96
102
|
|
|
97
|
-
### 3. 打开远程 WebUI
|
|
103
|
+
### 3. 打开远程 WebUI
|
|
98
104
|
|
|
99
|
-
|
|
105
|
+
优先使用 VS Code、Cursor 等 IDE 自带的端口转发。也可以在自己的电脑执行:
|
|
100
106
|
|
|
101
107
|
```bash
|
|
102
108
|
ssh -N -L <本地端口>:127.0.0.1:<DSH 实际端口> <服务器用户名>@<服务器公网 IP>
|
|
@@ -108,18 +114,67 @@ ssh -N -L <本地端口>:127.0.0.1:<DSH 实际端口> <服务器用户名>@<服
|
|
|
108
114
|
http://127.0.0.1:<本地端口>
|
|
109
115
|
```
|
|
110
116
|
|
|
111
|
-
### 4.
|
|
117
|
+
### 4. 在 UI 配置公网入口
|
|
118
|
+
|
|
119
|
+
打开左侧的“移动设备”,在“公网接入”填写云厂商控制台提供的公网 IPv4,然后点击“配置公网接入”或“更新公网配置”。Helper 会自动读取当前 `dsh web` 端口并配置 Nginx、TLS 证书和 `wss://<公网 IP>/ws/mobile`。
|
|
120
|
+
|
|
121
|
+
<p align="center">
|
|
122
|
+
<img src="docs/assets/public-access-ui.png" alt="在移动设备面板配置公网接入" width="420">
|
|
123
|
+
</p>
|
|
124
|
+
|
|
125
|
+
### 5. 配对移动设备
|
|
112
126
|
|
|
113
|
-
1.
|
|
114
|
-
2.
|
|
115
|
-
3.
|
|
116
|
-
4.
|
|
117
|
-
5. iPhone 打开“设备认证”并扫描二维码。
|
|
118
|
-
6. WebUI 的可信设备显示“在线”后即完成。
|
|
127
|
+
1. 开启“允许移动设备连接”,保持“设备鉴权”开启。
|
|
128
|
+
2. 填写设备名称并点击“生成配对二维码”。
|
|
129
|
+
3. iPhone 打开“设备认证”并扫描二维码。
|
|
130
|
+
4. WebUI 的可信设备显示“在线”后即完成。
|
|
119
131
|
|
|
120
132
|
二维码只能使用一次,并会在 5 分钟后过期;超时后在 WebUI 重新生成即可。
|
|
121
133
|
|
|
122
|
-
##
|
|
134
|
+
## Windows / macOS 家用电脑远程连接
|
|
135
|
+
|
|
136
|
+
家用电脑通常没有固定公网 IP,不建议配置路由器端口转发。可以使用 Tailscale 长期连接,或使用 Cloudflare Quick Tunnel 临时调试。两种方式都转发到插件专用的 `3081` 端口,不会公开 DSH WebUI。
|
|
137
|
+
|
|
138
|
+
使用前先启动 `dsh web`,并在“移动设备”面板开启“允许移动设备连接”和“设备鉴权”。
|
|
139
|
+
|
|
140
|
+
### Tailscale(推荐长期使用)
|
|
141
|
+
|
|
142
|
+
1. 在电脑和 iPhone 安装 [Tailscale](https://tailscale.com/download),并登录同一个 Tailnet。
|
|
143
|
+
2. 在 Windows PowerShell 或 macOS 终端执行:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
tailscale serve --bg 3081
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
3. 执行 `tailscale serve status` 查看生成的 `https://<设备名>.<tailnet>.ts.net` 地址。
|
|
150
|
+
4. 将地址改为 `wss://<设备名>.<tailnet>.ts.net/ws/mobile`,填入 WebUI 的“WebSocket 地址”,再生成二维码配对。
|
|
151
|
+
|
|
152
|
+
Tailscale Serve 只允许同一 Tailnet 中符合访问规则的设备连接,并自动提供 HTTPS。可用 `tailscale serve reset` 停止转发。参见 [Tailscale Serve 文档](https://tailscale.com/docs/reference/tailscale-cli/serve)。
|
|
153
|
+
|
|
154
|
+
### Cloudflare Quick Tunnel(仅临时调试)
|
|
155
|
+
|
|
156
|
+
1. 安装 [cloudflared](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/)。
|
|
157
|
+
2. 在 Windows PowerShell 或 macOS 终端执行:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
cloudflared tunnel --url http://127.0.0.1:3081
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
3. 命令行会显示随机的 `https://<随机名称>.trycloudflare.com` 地址。
|
|
164
|
+
4. 将地址改为 `wss://<随机名称>.trycloudflare.com/ws/mobile`,填入 WebUI 的“WebSocket 地址”,再生成二维码配对。
|
|
165
|
+
|
|
166
|
+
保持该命令运行;停止命令后隧道立即失效。Quick Tunnel 的地址每次可能变化,且没有可用性保证,不适合正式或长期使用。公网调试时必须保持“设备鉴权”开启。参见 [Cloudflare Quick Tunnel 文档](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/)。
|
|
167
|
+
|
|
168
|
+
## 网关配置方式总览
|
|
169
|
+
|
|
170
|
+
| 使用场景 | 推荐入口 | iOS WebSocket 地址 | 需要的额外配置 | 端口与鉴权 |
|
|
171
|
+
|---|---|---|---|---|
|
|
172
|
+
| 同一局域网 | 插件局域网入口 | `ws://<电脑局域网 IP>:3081/ws/mobile` | 无需 Helper 或 Nginx;电脑与 iPhone 位于可互访的局域网 | 仅对私有网络放行 TCP `3081`;保持鉴权开启 |
|
|
173
|
+
| 本机 iOS 模拟器 | DSH WebUI 本地入口 | `ws://127.0.0.1:<DSH WebUI 端口>/ws/mobile` | 无需 Helper、Nginx 或独立的 `3081` 端口 | 不开放任何外部端口;仅 Debug 时可关闭鉴权 |
|
|
174
|
+
| Linux 公网服务器 | 插件 Helper + Nginx + TLS | `wss://<服务器公网 IPv4>/ws/mobile` | 执行 `init`,再从 WebUI 填写公网 IPv4 | 云安全组放行 TCP `80/443`;不要公开 DSH 端口和 `3081`;必须鉴权 |
|
|
175
|
+
| 家用 Windows / macOS | Tailscale Serve;临时调试可用 Quick Tunnel | `wss://<Tailscale 域名>/ws/mobile` 或 `wss://<随机名称>.trycloudflare.com/ws/mobile` | 隧道转发到 `127.0.0.1:3081`,将生成的地址填入 WebUI | 无需路由器端口转发;保持鉴权开启 |
|
|
176
|
+
|
|
177
|
+
## Linux 服务器公网入口管理
|
|
123
178
|
|
|
124
179
|
查看状态:
|
|
125
180
|
|
|
@@ -162,8 +217,8 @@ sudo env "PATH=$PATH" npx --yes dsh-plugin-mobile-gateway@latest remove-helper
|
|
|
162
217
|
| WebUI 没有“移动设备” | 确认安装在 `web` profile,并完整重启 `dsh web` |
|
|
163
218
|
| iOS 收到 `503` | 回到 WebUI 开启“允许移动设备连接” |
|
|
164
219
|
| iOS 收到 `401` | 在 WebUI 重新生成二维码并配对 |
|
|
165
|
-
|
|
|
166
|
-
|
|
|
220
|
+
| Linux 服务器公网连接超时 | 检查云安全组、服务器防火墙和 TCP `80/443` |
|
|
221
|
+
| Linux 服务器公网地址没有显示 | 在“移动设备 → 公网接入”填写公网 IPv4 并点击更新 |
|
|
167
222
|
| 需要查看服务端日志 | 执行 `tail -f /tmp/mobile-gateway.log` |
|
|
168
223
|
|
|
169
224
|
## 源码开发
|
|
Binary file
|
package/lib/index.mjs
CHANGED
|
@@ -33,6 +33,10 @@
|
|
|
33
33
|
// view:"conversation" trims chunk/header events and tool output
|
|
34
34
|
// { "type": "attachment", "sessionId", "attachmentId" }
|
|
35
35
|
// -> verified image metadata + canonical base64 bytes
|
|
36
|
+
// { "type": "file-list", "requestId"?, "sessionId", "path"? }
|
|
37
|
+
// { "type": "file-download-open", "requestId", "sessionId", "path" }
|
|
38
|
+
// { "type": "file-download-read", "transferId", "offset" }
|
|
39
|
+
// { "type": "file-download-cancel", "transferId" }
|
|
36
40
|
// { "type": "search", "query" } -> session search
|
|
37
41
|
// { "type": "host" } -> host.describe snapshot (incl. default provider/model)
|
|
38
42
|
// { "type": "default-model" } -> agentDefaultModel.currentSelection()
|
|
@@ -54,18 +58,30 @@
|
|
|
54
58
|
// { "type": "set-default", "target": "agent-preset"|"permission", "value" }
|
|
55
59
|
// { "type": "question-answer", "rpcId", "sessionId", "answers": [...] }
|
|
56
60
|
// { "type": "question-cancel", "rpcId", "sessionId" }
|
|
57
|
-
//
|
|
61
|
+
// { "type": "approval-response", "rpcId", "sessionId", "approvalId",
|
|
62
|
+
// "outcome": "allowed-once"|"rejected" }
|
|
63
|
+
// server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images", "file-downloads"],
|
|
58
64
|
// "authenticated", "device"?, "port", "clients" }
|
|
59
65
|
// { "kind": "pong", "at" }
|
|
60
66
|
// { "kind": "subscribed", "sessionId" }
|
|
61
67
|
// { "kind": "sent", "sessionId", "mode", "command"? }
|
|
62
68
|
// { "kind": "workspaces" | "sessions" | "history" | "search", ...data }
|
|
63
69
|
// { "kind": "attachment", "sessionId", "attachment", "data" }
|
|
70
|
+
// { "kind": "file-list", "requestId"?, "sessionId", "path", "entries" }
|
|
71
|
+
// { "kind": "file-download-opened", "requestId", "transferId", "sessionId",
|
|
72
|
+
// "path", "name", "mediaType", "size", "chunkBytes" }
|
|
73
|
+
// { "kind": "file-download-chunk", "transferId", "offset", "data", "eof", "sha256"? }
|
|
74
|
+
// { "kind": "file-download-cancelled", "transferId" }
|
|
64
75
|
// { "kind": "error", "code", "message", "requestType"?, "sessionId"? }
|
|
65
76
|
// { "kind": "event", "sessionId", "seq", "time", "event": { ... } }
|
|
66
77
|
// { "kind": "question-requested", "rpcId", "sessionId", "questions", "replay"? }
|
|
67
78
|
// { "kind": "question-response", "rpcId", "sessionId", "action", "accepted", "reason"? }
|
|
68
79
|
// { "kind": "question-resolved", "rpcId", "sessionId", "outcome" }
|
|
80
|
+
// { "kind": "approval-requested", "rpcId", "sessionId", "approvalId",
|
|
81
|
+
// "toolName", "callId"?, "reason"?, "replay"? }
|
|
82
|
+
// { "kind": "approval-response", "rpcId", "sessionId", "approvalId",
|
|
83
|
+
// "outcome", "accepted", "reason"? }
|
|
84
|
+
// { "kind": "approval-resolved", "rpcId", "sessionId", "approvalId", "outcome" }
|
|
69
85
|
import fs from 'node:fs'
|
|
70
86
|
import fsp from 'node:fs/promises'
|
|
71
87
|
import http from 'node:http'
|
|
@@ -93,6 +109,11 @@ const HELPER_SOCKET = '/run/dsh-mobile-gateway/helper.sock'
|
|
|
93
109
|
const DEFAULT_WS_PATH = '/ws/mobile'
|
|
94
110
|
const DEFAULT_PAIRING_TTL_MS = 5 * 60 * 1000
|
|
95
111
|
const DEFAULT_GATEWAY_WAIT_TIMEOUT_MS = 5 * 60 * 1000
|
|
112
|
+
const DEFAULT_FILE_DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024
|
|
113
|
+
const DEFAULT_FILE_DOWNLOAD_CHUNK_BYTES = 512 * 1024
|
|
114
|
+
const DEFAULT_FILE_DOWNLOAD_IDLE_MS = 2 * 60 * 1000
|
|
115
|
+
const DEFAULT_FILE_DOWNLOAD_MAX_TRANSFERS = 4
|
|
116
|
+
const INTERACTION_PROTOCOL_REVISION = 'question-approval-v2'
|
|
96
117
|
// DSH 0.1.1 allows up to 100 MiB of decoded images in one prompt. Base64 plus
|
|
97
118
|
// the JSON envelope needs roughly 4/3 of that on the wire. Keep this separately
|
|
98
119
|
// configurable so deployments may choose a lower transport ceiling.
|
|
@@ -109,6 +130,11 @@ const Config = Schema.object({
|
|
|
109
130
|
gatewayEnabled: Schema.boolean().default(false),
|
|
110
131
|
gatewayWaitTimeoutMs: Schema.natural().min(30_000).max(30 * 60 * 1000).default(DEFAULT_GATEWAY_WAIT_TIMEOUT_MS),
|
|
111
132
|
maxPayloadBytes: Schema.natural().min(1024 * 1024).max(160 * 1024 * 1024).default(DEFAULT_MAX_WS_PAYLOAD_BYTES),
|
|
133
|
+
fileDownloadsEnabled: Schema.boolean().default(true),
|
|
134
|
+
fileDownloadMaxBytes: Schema.natural().min(1024 * 1024).max(2 * 1024 * 1024 * 1024).default(DEFAULT_FILE_DOWNLOAD_MAX_BYTES),
|
|
135
|
+
fileDownloadChunkBytes: Schema.natural().min(64 * 1024).max(1024 * 1024).default(DEFAULT_FILE_DOWNLOAD_CHUNK_BYTES),
|
|
136
|
+
fileDownloadIdleMs: Schema.natural().min(10_000).max(30 * 60 * 1000).default(DEFAULT_FILE_DOWNLOAD_IDLE_MS),
|
|
137
|
+
fileDownloadMaxTransfers: Schema.natural().min(1).max(16).default(DEFAULT_FILE_DOWNLOAD_MAX_TRANSFERS),
|
|
112
138
|
adminLoopbackOnly: Schema.boolean().default(true),
|
|
113
139
|
publicUrl: Schema.string().default(''),
|
|
114
140
|
deviceFile: Schema.string().default(''),
|
|
@@ -351,6 +377,320 @@ async function proxyQuery(api, type, method, payload, signal) {
|
|
|
351
377
|
}
|
|
352
378
|
}
|
|
353
379
|
|
|
380
|
+
function fileTransferError(code, message, requestType, sessionId) {
|
|
381
|
+
return {
|
|
382
|
+
kind: 'error',
|
|
383
|
+
code,
|
|
384
|
+
message,
|
|
385
|
+
requestType,
|
|
386
|
+
...(sessionId ? { sessionId } : {}),
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function fileMediaType(filePath) {
|
|
391
|
+
switch (path.extname(filePath).toLowerCase()) {
|
|
392
|
+
case '.apk': return 'application/vnd.android.package-archive'
|
|
393
|
+
case '.doc': return 'application/msword'
|
|
394
|
+
case '.docx': return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
|
395
|
+
case '.ipa': return 'application/octet-stream'
|
|
396
|
+
case '.json': return 'application/json'
|
|
397
|
+
case '.pdf': return 'application/pdf'
|
|
398
|
+
case '.ppt': return 'application/vnd.ms-powerpoint'
|
|
399
|
+
case '.pptx': return 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
|
400
|
+
case '.txt': return 'text/plain'
|
|
401
|
+
case '.xls': return 'application/vnd.ms-excel'
|
|
402
|
+
case '.xlsx': return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
|
403
|
+
case '.zip': return 'application/zip'
|
|
404
|
+
default: return 'application/octet-stream'
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function workspaceRelativePath(root, target) {
|
|
409
|
+
const relative = path.relative(root, target)
|
|
410
|
+
return relative ? relative.split(path.sep).join('/') : '.'
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function isInsideWorkspace(root, target) {
|
|
414
|
+
const relative = path.relative(root, target)
|
|
415
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function normalizedWorkspaceInput(value, { allowEmpty = false } = {}) {
|
|
419
|
+
if (value === undefined && allowEmpty) return '.'
|
|
420
|
+
if (typeof value !== 'string') return null
|
|
421
|
+
const trimmed = value.trim()
|
|
422
|
+
if (!trimmed && allowEmpty) return '.'
|
|
423
|
+
if (!trimmed || trimmed.includes('\0') || path.isAbsolute(trimmed)) return null
|
|
424
|
+
if (trimmed.split(/[\\/]+/).includes('..')) return null
|
|
425
|
+
return trimmed
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Owns only workspace-scoped, regular-file downloads. The host API has an
|
|
429
|
+
// image-only attachment read path, so arbitrary document/archive bytes must be
|
|
430
|
+
// guarded here instead of accepting a host path from the mobile client.
|
|
431
|
+
function createFileTransferManager(api, options) {
|
|
432
|
+
const transfers = new Map()
|
|
433
|
+
|
|
434
|
+
const closeTransfer = async (transferId) => {
|
|
435
|
+
const transfer = transfers.get(transferId)
|
|
436
|
+
if (!transfer) return false
|
|
437
|
+
transfers.delete(transferId)
|
|
438
|
+
try {
|
|
439
|
+
await transfer.handle.close()
|
|
440
|
+
} catch {
|
|
441
|
+
// A failed close cannot make a completed or cancelled transfer usable.
|
|
442
|
+
}
|
|
443
|
+
return true
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const resolveSessionRoot = async (sessionId, requestType) => {
|
|
447
|
+
const sessions = await proxyQuery(api, requestType, api.sessions.list.bind(api.sessions), {})
|
|
448
|
+
if (sessions.kind === 'error') return sessions
|
|
449
|
+
const item = Array.isArray(sessions.items)
|
|
450
|
+
? sessions.items.find((entry) => entry && String(entry.sessionId) === sessionId)
|
|
451
|
+
: null
|
|
452
|
+
if (!item) return fileTransferError('session-not-found', 'no such session', requestType, sessionId)
|
|
453
|
+
if (typeof item.cwd !== 'string' || !item.cwd.trim()) {
|
|
454
|
+
return fileTransferError('file-workspace-unavailable', 'the session has no working directory', requestType, sessionId)
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
const root = await fsp.realpath(item.cwd)
|
|
458
|
+
const stat = await fsp.stat(root)
|
|
459
|
+
if (!stat.isDirectory()) {
|
|
460
|
+
return fileTransferError('file-workspace-unavailable', 'the session working directory is unavailable', requestType, sessionId)
|
|
461
|
+
}
|
|
462
|
+
return { root }
|
|
463
|
+
} catch {
|
|
464
|
+
return fileTransferError('file-workspace-unavailable', 'the session working directory is unavailable', requestType, sessionId)
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const resolveWorkspaceTarget = async (sessionId, rawPath, requestType, inputOptions) => {
|
|
469
|
+
const requested = normalizedWorkspaceInput(rawPath, inputOptions)
|
|
470
|
+
if (!requested) {
|
|
471
|
+
return fileTransferError('bad-request', `${requestType} requires a relative workspace path`, requestType, sessionId)
|
|
472
|
+
}
|
|
473
|
+
const rootResult = await resolveSessionRoot(sessionId, requestType)
|
|
474
|
+
if (rootResult.kind === 'error') return rootResult
|
|
475
|
+
const candidate = path.resolve(rootResult.root, requested)
|
|
476
|
+
if (!isInsideWorkspace(rootResult.root, candidate)) {
|
|
477
|
+
return fileTransferError('file-not-allowed', 'path must stay inside the session working directory', requestType, sessionId)
|
|
478
|
+
}
|
|
479
|
+
try {
|
|
480
|
+
const target = await fsp.realpath(candidate)
|
|
481
|
+
if (!isInsideWorkspace(rootResult.root, target)) {
|
|
482
|
+
return fileTransferError('file-not-allowed', 'path must stay inside the session working directory', requestType, sessionId)
|
|
483
|
+
}
|
|
484
|
+
const stat = await fsp.stat(target)
|
|
485
|
+
return { root: rootResult.root, target, stat }
|
|
486
|
+
} catch (error) {
|
|
487
|
+
if (error && error.code === 'ENOENT') {
|
|
488
|
+
return fileTransferError('file-not-found', 'file does not exist', requestType, sessionId)
|
|
489
|
+
}
|
|
490
|
+
return fileTransferError('file-unreadable', 'file cannot be read', requestType, sessionId)
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const fileList = async (msg) => {
|
|
495
|
+
const session = requireSessionId(msg)
|
|
496
|
+
if (session.error) return session.error
|
|
497
|
+
const resolved = await resolveWorkspaceTarget(session.value, msg.path, 'file-list', { allowEmpty: true })
|
|
498
|
+
if (resolved.kind === 'error') return resolved
|
|
499
|
+
if (!resolved.stat.isDirectory()) {
|
|
500
|
+
return fileTransferError('file-not-directory', 'path is not a directory', 'file-list', session.value)
|
|
501
|
+
}
|
|
502
|
+
try {
|
|
503
|
+
const dirents = await fsp.readdir(resolved.target, { withFileTypes: true })
|
|
504
|
+
const entries = []
|
|
505
|
+
for (const dirent of dirents) {
|
|
506
|
+
if (dirent.isSymbolicLink()) continue
|
|
507
|
+
const child = path.join(resolved.target, dirent.name)
|
|
508
|
+
if (dirent.isDirectory()) {
|
|
509
|
+
entries.push({ name: dirent.name, path: workspaceRelativePath(resolved.root, child), kind: 'directory' })
|
|
510
|
+
} else if (dirent.isFile()) {
|
|
511
|
+
const stat = await fsp.stat(child)
|
|
512
|
+
entries.push({
|
|
513
|
+
name: dirent.name,
|
|
514
|
+
path: workspaceRelativePath(resolved.root, child),
|
|
515
|
+
kind: 'file',
|
|
516
|
+
bytes: stat.size,
|
|
517
|
+
modifiedAt: stat.mtimeMs,
|
|
518
|
+
mediaType: fileMediaType(child),
|
|
519
|
+
})
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
entries.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'directory' ? -1 : 1))
|
|
523
|
+
const requestId = typeof msg.requestId === 'string' && msg.requestId.trim() ? msg.requestId.trim() : null
|
|
524
|
+
return {
|
|
525
|
+
kind: 'file-list',
|
|
526
|
+
...(requestId ? { requestId } : {}),
|
|
527
|
+
sessionId: session.value,
|
|
528
|
+
path: workspaceRelativePath(resolved.root, resolved.target),
|
|
529
|
+
entries,
|
|
530
|
+
}
|
|
531
|
+
} catch {
|
|
532
|
+
return fileTransferError('file-unreadable', 'directory cannot be read', 'file-list', session.value)
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const open = async (client, msg) => {
|
|
537
|
+
const requestId = typeof msg.requestId === 'string' && msg.requestId.trim() ? msg.requestId.trim() : null
|
|
538
|
+
const session = requireSessionId(msg)
|
|
539
|
+
if (!requestId) return fileTransferError('bad-request', 'file-download-open requires a requestId', 'file-download-open')
|
|
540
|
+
if (session.error) return session.error
|
|
541
|
+
if (transfers.size >= options.fileDownloadMaxTransfers) {
|
|
542
|
+
return fileTransferError('file-transfer-limit', 'too many active file downloads', 'file-download-open', session.value)
|
|
543
|
+
}
|
|
544
|
+
const resolved = await resolveWorkspaceTarget(session.value, msg.path, 'file-download-open')
|
|
545
|
+
if (resolved.kind === 'error') return resolved
|
|
546
|
+
if (!resolved.stat.isFile()) {
|
|
547
|
+
return fileTransferError('file-not-regular', 'only regular files can be downloaded', 'file-download-open', session.value)
|
|
548
|
+
}
|
|
549
|
+
if (resolved.stat.size > options.fileDownloadMaxBytes) {
|
|
550
|
+
return fileTransferError('file-too-large', 'file exceeds the configured download limit', 'file-download-open', session.value)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
let handle
|
|
554
|
+
try {
|
|
555
|
+
// Reject a last-moment leaf symlink replacement after the realpath
|
|
556
|
+
// containment check above. macOS and Linux both support O_NOFOLLOW.
|
|
557
|
+
handle = await fsp.open(resolved.target, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
|
|
558
|
+
const opened = await handle.stat()
|
|
559
|
+
if (!opened.isFile() || opened.size !== resolved.stat.size) {
|
|
560
|
+
await handle.close()
|
|
561
|
+
return fileTransferError('file-changed', 'file changed before download could start', 'file-download-open', session.value)
|
|
562
|
+
}
|
|
563
|
+
} catch {
|
|
564
|
+
return fileTransferError('file-unreadable', 'file cannot be opened', 'file-download-open', session.value)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const transferId = crypto.randomUUID()
|
|
568
|
+
const transfer = {
|
|
569
|
+
client,
|
|
570
|
+
handle,
|
|
571
|
+
sessionId: session.value,
|
|
572
|
+
path: workspaceRelativePath(resolved.root, resolved.target),
|
|
573
|
+
name: path.basename(resolved.target),
|
|
574
|
+
mediaType: fileMediaType(resolved.target),
|
|
575
|
+
size: resolved.stat.size,
|
|
576
|
+
offset: 0,
|
|
577
|
+
hash: crypto.createHash('sha256'),
|
|
578
|
+
reading: false,
|
|
579
|
+
lastActiveAt: Date.now(),
|
|
580
|
+
}
|
|
581
|
+
transfers.set(transferId, transfer)
|
|
582
|
+
log(`file download opened: transfer=${transferId} session=${transfer.sessionId} bytes=${transfer.size}`)
|
|
583
|
+
return {
|
|
584
|
+
kind: 'file-download-opened',
|
|
585
|
+
requestId,
|
|
586
|
+
transferId,
|
|
587
|
+
sessionId: transfer.sessionId,
|
|
588
|
+
path: transfer.path,
|
|
589
|
+
name: transfer.name,
|
|
590
|
+
mediaType: transfer.mediaType,
|
|
591
|
+
size: transfer.size,
|
|
592
|
+
chunkBytes: options.fileDownloadChunkBytes,
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const read = async (client, msg) => {
|
|
597
|
+
const transferId = typeof msg.transferId === 'string' && msg.transferId.trim() ? msg.transferId.trim() : null
|
|
598
|
+
if (!transferId) return fileTransferError('bad-request', 'file-download-read requires a transferId', 'file-download-read')
|
|
599
|
+
const transfer = transfers.get(transferId)
|
|
600
|
+
if (!transfer || transfer.client !== client) {
|
|
601
|
+
return fileTransferError('file-transfer-not-found', 'file download is no longer active', 'file-download-read')
|
|
602
|
+
}
|
|
603
|
+
if (!Number.isSafeInteger(msg.offset) || msg.offset !== transfer.offset) {
|
|
604
|
+
return fileTransferError('file-transfer-offset', 'offset must match the next unread byte', 'file-download-read', transfer.sessionId)
|
|
605
|
+
}
|
|
606
|
+
if (transfer.reading) {
|
|
607
|
+
return fileTransferError('file-transfer-busy', 'a chunk read is already in progress', 'file-download-read', transfer.sessionId)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
transfer.reading = true
|
|
611
|
+
try {
|
|
612
|
+
const remaining = transfer.size - transfer.offset
|
|
613
|
+
const bytesToRead = Math.min(options.fileDownloadChunkBytes, remaining)
|
|
614
|
+
const chunk = Buffer.allocUnsafe(bytesToRead)
|
|
615
|
+
const { bytesRead } = bytesToRead === 0
|
|
616
|
+
? { bytesRead: 0 }
|
|
617
|
+
: await transfer.handle.read(chunk, 0, bytesToRead, transfer.offset)
|
|
618
|
+
if (bytesRead !== bytesToRead) {
|
|
619
|
+
await closeTransfer(transferId)
|
|
620
|
+
return fileTransferError('file-changed', 'file changed during download', 'file-download-read', transfer.sessionId)
|
|
621
|
+
}
|
|
622
|
+
const payload = bytesRead === chunk.length ? chunk : chunk.subarray(0, bytesRead)
|
|
623
|
+
transfer.hash.update(payload)
|
|
624
|
+
const offset = transfer.offset
|
|
625
|
+
transfer.offset += bytesRead
|
|
626
|
+
transfer.lastActiveAt = Date.now()
|
|
627
|
+
const eof = transfer.offset === transfer.size
|
|
628
|
+
const frame = {
|
|
629
|
+
kind: 'file-download-chunk',
|
|
630
|
+
transferId,
|
|
631
|
+
offset,
|
|
632
|
+
data: payload.toString('base64'),
|
|
633
|
+
eof,
|
|
634
|
+
}
|
|
635
|
+
if (eof) {
|
|
636
|
+
frame.sha256 = transfer.hash.digest('hex')
|
|
637
|
+
await closeTransfer(transferId)
|
|
638
|
+
log(`file download completed: transfer=${transferId} bytes=${transfer.size}`)
|
|
639
|
+
}
|
|
640
|
+
return frame
|
|
641
|
+
} catch {
|
|
642
|
+
await closeTransfer(transferId)
|
|
643
|
+
return fileTransferError('file-unreadable', 'file cannot be read', 'file-download-read', transfer.sessionId)
|
|
644
|
+
} finally {
|
|
645
|
+
transfer.reading = false
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const cancel = async (client, msg) => {
|
|
650
|
+
const transferId = typeof msg.transferId === 'string' && msg.transferId.trim() ? msg.transferId.trim() : null
|
|
651
|
+
if (!transferId) return fileTransferError('bad-request', 'file-download-cancel requires a transferId', 'file-download-cancel')
|
|
652
|
+
const transfer = transfers.get(transferId)
|
|
653
|
+
if (!transfer || transfer.client !== client) {
|
|
654
|
+
return fileTransferError('file-transfer-not-found', 'file download is no longer active', 'file-download-cancel')
|
|
655
|
+
}
|
|
656
|
+
await closeTransfer(transferId)
|
|
657
|
+
log(`file download cancelled: transfer=${transferId}`)
|
|
658
|
+
return { kind: 'file-download-cancelled', transferId }
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const expire = async () => {
|
|
662
|
+
const cutoff = Date.now() - options.fileDownloadIdleMs
|
|
663
|
+
for (const [transferId, transfer] of transfers) {
|
|
664
|
+
if (transfer.lastActiveAt < cutoff && !transfer.reading) {
|
|
665
|
+
await closeTransfer(transferId)
|
|
666
|
+
log(`file download expired: transfer=${transferId}`)
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return {
|
|
672
|
+
async handle(client, msg) {
|
|
673
|
+
if (!options.fileDownloadsEnabled) {
|
|
674
|
+
return fileTransferError('file-download-disabled', 'file downloads are disabled by gateway configuration', msg.type)
|
|
675
|
+
}
|
|
676
|
+
if (msg.type === 'file-list') return fileList(msg)
|
|
677
|
+
if (msg.type === 'file-download-open') return open(client, msg)
|
|
678
|
+
if (msg.type === 'file-download-read') return read(client, msg)
|
|
679
|
+
if (msg.type === 'file-download-cancel') return cancel(client, msg)
|
|
680
|
+
return null
|
|
681
|
+
},
|
|
682
|
+
async closeClient(client) {
|
|
683
|
+
for (const [transferId, transfer] of transfers) {
|
|
684
|
+
if (transfer.client === client) await closeTransfer(transferId)
|
|
685
|
+
}
|
|
686
|
+
},
|
|
687
|
+
async dispose() {
|
|
688
|
+
for (const transferId of [...transfers.keys()]) await closeTransfer(transferId)
|
|
689
|
+
},
|
|
690
|
+
expire,
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
354
694
|
// List one directory level directly with node:fs, mirroring the official
|
|
355
695
|
// browse backend's semantics (crumbs + name-sorted child directories). Works
|
|
356
696
|
// on every deployment regardless of the composed picker capability, which
|
|
@@ -689,7 +1029,9 @@ async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
|
|
|
689
1029
|
const execution = await typertGateway.invoke({
|
|
690
1030
|
namespace: 'commands',
|
|
691
1031
|
method: 'execute',
|
|
692
|
-
|
|
1032
|
+
// commands/execute 的当前 Typert descriptor 要求 images 字段始终存在;
|
|
1033
|
+
// 权限斜杠命令没有附件,因此显式传空数组。
|
|
1034
|
+
args: { agentId: sessionId.value, line: '/permission ' + name, images: [] },
|
|
693
1035
|
signal: new AbortController().signal,
|
|
694
1036
|
})
|
|
695
1037
|
if (execution === undefined || execution === null) {
|
|
@@ -1013,6 +1355,11 @@ const plugin = {
|
|
|
1013
1355
|
gatewayEnabled: false,
|
|
1014
1356
|
gatewayWaitTimeoutMs: DEFAULT_GATEWAY_WAIT_TIMEOUT_MS,
|
|
1015
1357
|
maxPayloadBytes: DEFAULT_MAX_WS_PAYLOAD_BYTES,
|
|
1358
|
+
fileDownloadsEnabled: true,
|
|
1359
|
+
fileDownloadMaxBytes: DEFAULT_FILE_DOWNLOAD_MAX_BYTES,
|
|
1360
|
+
fileDownloadChunkBytes: DEFAULT_FILE_DOWNLOAD_CHUNK_BYTES,
|
|
1361
|
+
fileDownloadIdleMs: DEFAULT_FILE_DOWNLOAD_IDLE_MS,
|
|
1362
|
+
fileDownloadMaxTransfers: DEFAULT_FILE_DOWNLOAD_MAX_TRANSFERS,
|
|
1016
1363
|
adminLoopbackOnly: true,
|
|
1017
1364
|
publicUrl: '',
|
|
1018
1365
|
deviceFile: '',
|
|
@@ -1050,7 +1397,14 @@ const plugin = {
|
|
|
1050
1397
|
})
|
|
1051
1398
|
const clients = new Set()
|
|
1052
1399
|
const pendingQuestions = new Map()
|
|
1053
|
-
const
|
|
1400
|
+
const pendingApprovals = new Map()
|
|
1401
|
+
const fileTransfers = createFileTransferManager(api, options)
|
|
1402
|
+
const fileTransferExpiryTimer = setInterval(() => {
|
|
1403
|
+
fileTransfers.expire().catch((error) => {
|
|
1404
|
+
log(`file download expiry failed: ${error && error.message ? error.message : String(error)}`)
|
|
1405
|
+
})
|
|
1406
|
+
}, Math.min(options.fileDownloadIdleMs, 30_000))
|
|
1407
|
+
const interactionMuxAbort = new AbortController()
|
|
1054
1408
|
let counter = 0
|
|
1055
1409
|
let gatewayEnabled = options.gatewayEnabled === true
|
|
1056
1410
|
let waitExpiresAt = null
|
|
@@ -1120,7 +1474,18 @@ const plugin = {
|
|
|
1120
1474
|
...(replay ? { replay: true } : {}),
|
|
1121
1475
|
})
|
|
1122
1476
|
|
|
1123
|
-
const
|
|
1477
|
+
const approvalFrameFor = (rpcId, payload, replay = false) => ({
|
|
1478
|
+
kind: 'approval-requested',
|
|
1479
|
+
rpcId: String(rpcId),
|
|
1480
|
+
sessionId: String(payload.sessionId),
|
|
1481
|
+
approvalId: String(payload.approvalId),
|
|
1482
|
+
toolName: payload.toolName,
|
|
1483
|
+
...(payload.callId !== undefined ? { callId: payload.callId } : {}),
|
|
1484
|
+
...(payload.reason !== undefined ? { reason: payload.reason } : {}),
|
|
1485
|
+
...(replay ? { replay: true } : {}),
|
|
1486
|
+
})
|
|
1487
|
+
|
|
1488
|
+
const broadcastInteractionFrame = (frame) => {
|
|
1124
1489
|
const wire = JSON.stringify(frame)
|
|
1125
1490
|
for (const client of clients) {
|
|
1126
1491
|
if (client.filterSessionId && client.filterSessionId !== frame.sessionId) continue
|
|
@@ -1128,6 +1493,23 @@ const plugin = {
|
|
|
1128
1493
|
}
|
|
1129
1494
|
}
|
|
1130
1495
|
|
|
1496
|
+
const replayPendingInteractions = (ws, trigger) => {
|
|
1497
|
+
let questionCount = 0
|
|
1498
|
+
let approvalCount = 0
|
|
1499
|
+
for (const pending of pendingQuestions.values()) {
|
|
1500
|
+
if (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
|
|
1501
|
+
ws.send(JSON.stringify(questionFrameFor(pending.rpcId, pending, true)))
|
|
1502
|
+
questionCount += 1
|
|
1503
|
+
}
|
|
1504
|
+
for (const pending of pendingApprovals.values()) {
|
|
1505
|
+
if (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
|
|
1506
|
+
ws.send(JSON.stringify(approvalFrameFor(pending.rpcId, pending, true)))
|
|
1507
|
+
approvalCount += 1
|
|
1508
|
+
}
|
|
1509
|
+
log(`interaction replay: trigger=${trigger} filtered=${Boolean(ws.filterSessionId)} questions=${questionCount} approvals=${approvalCount}`)
|
|
1510
|
+
return { questionCount, approvalCount }
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1131
1513
|
const respondToQuestion = async (msg, cancel = false) => {
|
|
1132
1514
|
const rpcId = typeof msg.rpcId === 'string' && msg.rpcId.trim() ? msg.rpcId.trim() : null
|
|
1133
1515
|
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() ? msg.sessionId.trim() : null
|
|
@@ -1178,9 +1560,54 @@ const plugin = {
|
|
|
1178
1560
|
}
|
|
1179
1561
|
}
|
|
1180
1562
|
|
|
1181
|
-
|
|
1563
|
+
const respondToApproval = async (msg) => {
|
|
1564
|
+
const rpcId = typeof msg.rpcId === 'string' && msg.rpcId.trim() ? msg.rpcId.trim() : null
|
|
1565
|
+
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() ? msg.sessionId.trim() : null
|
|
1566
|
+
const approvalId = typeof msg.approvalId === 'string' && msg.approvalId.trim() ? msg.approvalId.trim() : null
|
|
1567
|
+
const outcome = msg.outcome
|
|
1568
|
+
if (!rpcId || !sessionId || !approvalId) {
|
|
1569
|
+
return { kind: 'error', code: 'bad-request', message: 'approval-response requires rpcId, sessionId, and approvalId', requestType: msg.type }
|
|
1570
|
+
}
|
|
1571
|
+
if (outcome !== 'allowed-once' && outcome !== 'rejected') {
|
|
1572
|
+
return { kind: 'error', code: 'bad-request', message: 'approval-response outcome must be "allowed-once" or "rejected"', requestType: msg.type, sessionId }
|
|
1573
|
+
}
|
|
1182
1574
|
|
|
1183
|
-
|
|
1575
|
+
const pending = pendingApprovals.get(rpcId)
|
|
1576
|
+
if (pending && (pending.sessionId !== sessionId || pending.approvalId !== approvalId)) {
|
|
1577
|
+
return { kind: 'error', code: 'bad-request', message: 'approval-response does not match the pending approval', requestType: msg.type, sessionId }
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
try {
|
|
1581
|
+
const receipt = await api.respond({
|
|
1582
|
+
type: 'client-response',
|
|
1583
|
+
rpcId,
|
|
1584
|
+
result: { ok: true, value: { sessionId, approvalId, outcome } },
|
|
1585
|
+
})
|
|
1586
|
+
log(`approval response: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} outcome=${outcome} accepted=${receipt.accepted}${receipt.accepted ? '' : ` reason=${receipt.reason}`}`)
|
|
1587
|
+
return {
|
|
1588
|
+
kind: 'approval-response',
|
|
1589
|
+
rpcId,
|
|
1590
|
+
sessionId,
|
|
1591
|
+
approvalId,
|
|
1592
|
+
outcome,
|
|
1593
|
+
accepted: receipt.accepted,
|
|
1594
|
+
...(!receipt.accepted ? { reason: receipt.reason } : {}),
|
|
1595
|
+
}
|
|
1596
|
+
} catch (error) {
|
|
1597
|
+
const message = error && error.message ? error.message : String(error)
|
|
1598
|
+
log(`approval response failed: rpcId=${rpcId} ${message}`)
|
|
1599
|
+
return { kind: 'error', code: 'internal', message, requestType: msg.type, sessionId }
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// Startup-config enablement (gatewayEnabled: true in cordis.patch.yml) is a
|
|
1604
|
+
// standing-service decision made by the operator: do NOT arm the no-device
|
|
1605
|
+
// auto-close. That safety net stays attached to the ephemeral panel toggle,
|
|
1606
|
+
// where it belongs. gatewayEnabled is already true from options at this point,
|
|
1607
|
+
// so nothing else is needed here beyond the log line.
|
|
1608
|
+
if (gatewayEnabled) log('mobile gateway enabled by startup config: standing mode, no auto-close timer')
|
|
1609
|
+
|
|
1610
|
+
log(`applying: version=${PLUGIN_VERSION} interactionProtocol=${INTERACTION_PROTOCOL_REVISION} path=${wsPath}, webServer.port=${webServer.port}, gatewayEnabled=${gatewayEnabled}, requireAuth=${requireAuth}, devices=${registry.count()}`)
|
|
1184
1611
|
|
|
1185
1612
|
// ---- device management routes (loopback-gated admin surface) ----
|
|
1186
1613
|
const disposeMgmt = webServer.register({
|
|
@@ -1374,6 +1801,7 @@ const plugin = {
|
|
|
1374
1801
|
} else if (msg.type === 'subscribe') {
|
|
1375
1802
|
ws.filterSessionId = typeof msg.sessionId === 'string' ? msg.sessionId : undefined
|
|
1376
1803
|
ws.send(JSON.stringify({ kind: 'subscribed', sessionId: ws.filterSessionId || null }))
|
|
1804
|
+
replayPendingInteractions(ws, 'subscribe')
|
|
1377
1805
|
} else if (msg.type === 'unsubscribe') {
|
|
1378
1806
|
ws.filterSessionId = undefined
|
|
1379
1807
|
ws.send(JSON.stringify({ kind: 'subscribed', sessionId: null }))
|
|
@@ -1381,6 +1809,15 @@ const plugin = {
|
|
|
1381
1809
|
respondToQuestion(msg, msg.type === 'question-cancel').then((frame) => {
|
|
1382
1810
|
if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
|
|
1383
1811
|
})
|
|
1812
|
+
} else if (msg.type === 'approval-response') {
|
|
1813
|
+
respondToApproval(msg).then((frame) => {
|
|
1814
|
+
if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
|
|
1815
|
+
})
|
|
1816
|
+
} else if (msg.type === 'file-list' || msg.type === 'file-download-open' ||
|
|
1817
|
+
msg.type === 'file-download-read' || msg.type === 'file-download-cancel') {
|
|
1818
|
+
fileTransfers.handle(ws, msg).then((frame) => {
|
|
1819
|
+
if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
|
|
1820
|
+
})
|
|
1384
1821
|
} else if (msg.type === 'message') {
|
|
1385
1822
|
admitMessage(api, msg).then((frame) => {
|
|
1386
1823
|
if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
|
|
@@ -1403,6 +1840,9 @@ const plugin = {
|
|
|
1403
1840
|
ws.on('close', () => {
|
|
1404
1841
|
clients.delete(ws)
|
|
1405
1842
|
if (ws.deviceId) registry.disconnected(ws.deviceId)
|
|
1843
|
+
fileTransfers.closeClient(ws).catch((error) => {
|
|
1844
|
+
log(`file download cleanup failed: ${error && error.message ? error.message : String(error)}`)
|
|
1845
|
+
})
|
|
1406
1846
|
log(`client disconnected (id=${id}, remaining=${clients.size})`)
|
|
1407
1847
|
})
|
|
1408
1848
|
|
|
@@ -1417,16 +1857,13 @@ const plugin = {
|
|
|
1417
1857
|
ws.send(JSON.stringify({
|
|
1418
1858
|
kind: 'hello',
|
|
1419
1859
|
protocol: 3,
|
|
1420
|
-
capabilities: ['images'],
|
|
1860
|
+
capabilities: ['images', ...(options.fileDownloadsEnabled ? ['file-downloads'] : [])],
|
|
1421
1861
|
port: transport.port || webServer.port,
|
|
1422
1862
|
clients: clients.size,
|
|
1423
1863
|
authenticated: !!device,
|
|
1424
1864
|
...(device ? { device: { id: device.id, name: device.name } } : {}),
|
|
1425
1865
|
}))
|
|
1426
|
-
|
|
1427
|
-
if (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
|
|
1428
|
-
ws.send(JSON.stringify(questionFrameFor(pending.rpcId, pending, true)))
|
|
1429
|
-
}
|
|
1866
|
+
replayPendingInteractions(ws, 'connect')
|
|
1430
1867
|
})
|
|
1431
1868
|
}
|
|
1432
1869
|
|
|
@@ -1491,15 +1928,16 @@ const plugin = {
|
|
|
1491
1928
|
})
|
|
1492
1929
|
log('session/event listener attached')
|
|
1493
1930
|
|
|
1494
|
-
const
|
|
1931
|
+
const interactionMuxTask = api.events && typeof api.events.mux === 'function' && typeof api.respond === 'function'
|
|
1495
1932
|
? (async () => {
|
|
1496
1933
|
try {
|
|
1497
1934
|
for await (const envelope of api.events.mux(
|
|
1498
1935
|
{ rpcId: crypto.randomUUID(), payload: {} },
|
|
1499
|
-
|
|
1936
|
+
interactionMuxAbort.signal,
|
|
1500
1937
|
)) {
|
|
1501
1938
|
const payload = envelope && envelope.payload
|
|
1502
1939
|
if (!payload || typeof payload.type !== 'string') continue
|
|
1940
|
+
log(`interaction observed: type=${payload.type} hasRpc=${envelope.rpcId !== undefined} hasSession=${payload.sessionId !== undefined} hasApprovalId=${payload.approvalId !== undefined} hasToolName=${payload.toolName !== undefined}`)
|
|
1503
1941
|
if (payload.type === 'question/requested') {
|
|
1504
1942
|
const rpcId = String(envelope.rpcId)
|
|
1505
1943
|
const pending = {
|
|
@@ -1508,34 +1946,72 @@ const plugin = {
|
|
|
1508
1946
|
questions: payload.questions,
|
|
1509
1947
|
}
|
|
1510
1948
|
pendingQuestions.set(rpcId, pending)
|
|
1511
|
-
|
|
1949
|
+
broadcastInteractionFrame(questionFrameFor(rpcId, pending))
|
|
1512
1950
|
log(`question requested: rpcId=${rpcId} session=${pending.sessionId} questions=${Array.isArray(pending.questions) ? pending.questions.length : 0}`)
|
|
1951
|
+
} else if (payload.type === 'approval/requested') {
|
|
1952
|
+
const rpcId = String(envelope.rpcId)
|
|
1953
|
+
const pending = {
|
|
1954
|
+
rpcId,
|
|
1955
|
+
sessionId: String(payload.sessionId),
|
|
1956
|
+
approvalId: String(payload.approvalId),
|
|
1957
|
+
toolName: payload.toolName,
|
|
1958
|
+
...(payload.callId !== undefined ? { callId: payload.callId } : {}),
|
|
1959
|
+
...(payload.reason !== undefined ? { reason: payload.reason } : {}),
|
|
1960
|
+
}
|
|
1961
|
+
pendingApprovals.set(rpcId, pending)
|
|
1962
|
+
broadcastInteractionFrame(approvalFrameFor(rpcId, pending))
|
|
1963
|
+
log(`approval requested: rpcId=${rpcId} approvalId=${pending.approvalId} session=${pending.sessionId} tool=${pending.toolName}`)
|
|
1513
1964
|
} else if (payload.type === 'question/resolved') {
|
|
1514
1965
|
const questionRpcId = String(payload.questionRpcId)
|
|
1515
1966
|
pendingQuestions.delete(questionRpcId)
|
|
1516
|
-
|
|
1967
|
+
broadcastInteractionFrame({
|
|
1517
1968
|
kind: 'question-resolved',
|
|
1518
1969
|
rpcId: questionRpcId,
|
|
1519
1970
|
sessionId: String(payload.sessionId),
|
|
1520
1971
|
outcome: payload.outcome,
|
|
1521
1972
|
})
|
|
1522
1973
|
log(`question resolved: rpcId=${questionRpcId} session=${payload.sessionId} outcome=${payload.outcome}`)
|
|
1974
|
+
} else if (payload.type === 'approval/resolved') {
|
|
1975
|
+
const approvalId = String(payload.approvalId)
|
|
1976
|
+
const sessionId = String(payload.sessionId)
|
|
1977
|
+
const entry = [...pendingApprovals.entries()].find(([, pending]) => pending.approvalId === approvalId && pending.sessionId === sessionId)
|
|
1978
|
+
if (!entry) {
|
|
1979
|
+
log(`approval resolved without pending request: approvalId=${approvalId} session=${sessionId} outcome=${payload.outcome}`)
|
|
1980
|
+
continue
|
|
1981
|
+
}
|
|
1982
|
+
const [rpcId, pending] = entry
|
|
1983
|
+
pendingApprovals.delete(rpcId)
|
|
1984
|
+
broadcastInteractionFrame({
|
|
1985
|
+
kind: 'approval-resolved',
|
|
1986
|
+
rpcId,
|
|
1987
|
+
sessionId,
|
|
1988
|
+
approvalId,
|
|
1989
|
+
outcome: payload.outcome,
|
|
1990
|
+
})
|
|
1991
|
+
log(`approval resolved: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} outcome=${payload.outcome}`)
|
|
1992
|
+
} else {
|
|
1993
|
+
log(`interaction ignored: type=${payload.type}`)
|
|
1523
1994
|
}
|
|
1524
1995
|
}
|
|
1525
1996
|
} catch (error) {
|
|
1526
|
-
if (!
|
|
1527
|
-
log(`
|
|
1997
|
+
if (!interactionMuxAbort.signal.aborted) {
|
|
1998
|
+
log(`interaction mux failed: ${error && error.message ? error.message : String(error)}`)
|
|
1528
1999
|
}
|
|
1529
2000
|
}
|
|
1530
2001
|
})()
|
|
1531
2002
|
: null
|
|
1532
|
-
if (
|
|
1533
|
-
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')
|
|
1534
2005
|
|
|
1535
2006
|
ctx.effect(() => () => {
|
|
1536
2007
|
if (waitTimer) clearTimeout(waitTimer)
|
|
1537
|
-
|
|
2008
|
+
clearInterval(fileTransferExpiryTimer)
|
|
2009
|
+
interactionMuxAbort.abort()
|
|
1538
2010
|
pendingQuestions.clear()
|
|
2011
|
+
pendingApprovals.clear()
|
|
2012
|
+
fileTransfers.dispose().catch((error) => {
|
|
2013
|
+
log(`file download disposal failed: ${error && error.message ? error.message : String(error)}`)
|
|
2014
|
+
})
|
|
1539
2015
|
disposeUpgrade()
|
|
1540
2016
|
disposeMgmt()
|
|
1541
2017
|
disposeEvents()
|
package/package.json
CHANGED