dsh-plugin-mobile-gateway 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PROTOCOL.md +32 -2
- package/README.md +5 -3
- package/docs/dsh-0.2.0-rc.2-plugin-management-mobile-integration.md +109 -0
- package/lib/client.js +20 -1
- package/lib/cloudflare-tunnel.mjs +15 -1
- package/lib/dsh-host-adapter.mjs +28 -1
- package/lib/index.mjs +53 -4
- package/lib/plugin-management.mjs +194 -0
- package/package.json +3 -3
package/PROTOCOL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh Mobile Gateway — WebSocket 协议参考
|
|
2
2
|
|
|
3
|
-
移动端通过经过设备鉴权的 WebSocket 连接与 dsh 通信:订阅 agent 实时输出、发送文字和图片、处理 Human-in-the-loop 提问与操作审批、查询会话/工作区/历史、调整会话配置。本协议由持久化插件 `dsh-plugin-mobile-gateway` 实现。当前源码适配 DSH 0.
|
|
3
|
+
移动端通过经过设备鉴权的 WebSocket 连接与 dsh 通信:订阅 agent 实时输出、发送文字和图片、处理 Human-in-the-loop 提问与操作审批、查询会话/工作区/历史、调整会话配置。本协议由持久化插件 `dsh-plugin-mobile-gateway` 实现。当前源码适配 DSH 0.2.0-rc.2 / Session format 4;移动端实时流接入见 [rc.2 接入说明](docs/dsh-rc2-mobile-integration.md),插件管理见 [RC2 插件管理对接方案](docs/dsh-0.2.0-rc.2-plugin-management-mobile-integration.md)。
|
|
4
4
|
|
|
5
5
|
- **本机端点**:`ws://127.0.0.1:3080/ws/mobile`(与 dsh web GUI 同端口)
|
|
6
6
|
- **局域网端点**:`ws://<电脑的私有局域网 IP>:3081/ws/mobile`(插件独立监听,只提供经过鉴权的 WebSocket)
|
|
@@ -110,6 +110,8 @@ const pairingText = Buffer.from(JSON.stringify(payload), 'utf8').toString('base6
|
|
|
110
110
|
|
|
111
111
|
Windows/macOS PC 可从本机管理接口 `POST /mgw/cloudflare` 一键启用 Quick Tunnel:`{"enabled":true,"mode":"quick"}`,无需账户、域名或 Token;返回的随机公网地址在 `GET /mgw/status` 的 `cloudflare.publicUrl` 中,隧道重启后可能变化,App 需重新扫码确认。固定地址可用命名 Tunnel:`{"enabled":true,"mode":"named","hostname":"gateway.example.com","token":"<Tunnel Token>"}`;后续更新可省略 Token 或传空字符串以保留已保存的 Token,关闭时只需 `{"enabled":false}`。状态字段返回模式、支持状态、公开地址、本机入口端口、连接状态和错误,但绝不返回 Token。启用时网关自动切为常驻模式;配置与 Token 保存在独立的 `0600` 文件中。插件会在需要时下载并校验 Cloudflare 官方 `cloudflared` 二进制。两种模式仅在 `127.0.0.1:3082`(可配置)接受已鉴权的 `/ws/mobile` WebSocket,其他 HTTP 路径返回 404;网关关闭或插件卸载时该入口及受管进程一同停止。
|
|
112
112
|
|
|
113
|
+
`POST /mgw/cloudflare/restart` 无需请求体,仅在网关与 Tunnel 均已开启时重启隧道;未开启时返回 `409`。当前移动连接会断开,Quick Tunnel 的地址可能变化,客户端需重新扫码确认。此操作不会重启 DSH Web,也不会将 `cloudflared` 自动更新到最新版本。
|
|
114
|
+
|
|
113
115
|
完整配对示例、旧 App 迁移、地址信任与错误处理见 [App 对接说明](docs/multi-gateway-app-integration.md)。
|
|
114
116
|
|
|
115
117
|
#### iOS 对接示例
|
|
@@ -1088,6 +1090,12 @@ Control/旧单连接接收全局模式状态增量,不受所订阅会话过滤
|
|
|
1088
1090
|
- `tool/result` → `{turn, step, callId, isError, preview(≤400字符)}`
|
|
1089
1091
|
- `turn/start|end` / `step/start|end` → `{turn, step, reason?}`
|
|
1090
1092
|
|
|
1093
|
+
### 运行中计时提示
|
|
1094
|
+
|
|
1095
|
+
DSH Web 的“深度求索中,用时 22 秒 ···”是客户端文案,不作为独立 Host 事件或网关帧发送。移动端可用 `sessions.items[].running` 判断会话是否运行;订阅中的 `event` 帧在根节点带 `time`,`event.type: turn/start` 标记本轮开始,`turn/end` 标记结束。收到 `turn/start` 后以 `Date.now() - time` 在本地更新秒数,收到 `turn/end` 或会话 `running: false` 后停止。计时文本、单位和动画由移动端本地化渲染。
|
|
1096
|
+
|
|
1097
|
+
重连得到的 `session-snapshot.events` 只含最近窗口,长时间运行的 Turn 可能已把 `turn/start` 挤出窗口。此时 `sessions.items[].running` 仍可用来显示“深度求索中”,但不能只凭快照推断准确的起始时间;若需要恢复精确计时,应沿 `nextBeforeSeq` 读取更早历史,找到最近尚未结束的 `turn/start`。未取得起始时间前不要从重连时刻重新计算并声称是整轮耗时。
|
|
1098
|
+
|
|
1091
1099
|
---
|
|
1092
1100
|
|
|
1093
1101
|
## 14. 端到端示例(Postman)
|
|
@@ -1122,7 +1130,7 @@ Control/旧单连接接收全局模式状态增量,不受所订阅会话过滤
|
|
|
1122
1130
|
|
|
1123
1131
|
| 版本 | 新增 |
|
|
1124
1132
|
|---|---|
|
|
1125
|
-
| v0.
|
|
1133
|
+
| v0.9.0(当前源码) | DSH 0.2.0-rc.2 插件管理;PC 端 Cloudflare Quick / 命名 Tunnel 及独立重启;插件私有 `cloudflared` 缓存及校验;配对入口选择指引;网关重启与 DSH Web 停止工具 |
|
|
1126
1134
|
| v0.7.2 | 独立对话/控制连接;空 Session 创建;停止生成与稍后继续;排队消息同步及编辑/删除/Steer;App 归档/重命名 Session;WebUI 归档集合和名称变化实时同步到 App |
|
|
1127
1135
|
| v0.1.5 | workspace-create / directories / host |
|
|
1128
1136
|
| v0.1.6 | 修复消息分发器遗漏(host/directories/workspace-create 未路由) |
|
|
@@ -1167,6 +1175,28 @@ Control/旧单连接接收全局模式状态增量,不受所订阅会话过滤
|
|
|
1167
1175
|
|
|
1168
1176
|
rc.2 没有 `schedule/create` Remote 方法。新提醒由 Agent 的 `schedule_create` 工具创建;到期提醒作为 `source.kind: schedule` 的消息进入原 Session。Host 的无载荷 `schedule/changed` 被投影为移动端 `schedule-changed`;实现定时任务管理页面的客户端应在收到通知、重连或完成更新/删除后重新查询目录。当前移动端只接入协议请求、解码与变更帧识别,尚未提供管理页面。`hello.capabilities` 中的 `schedule-management` 表示网关支持这些请求,实际使用仍取决于 Host 是否启用定时任务。
|
|
1169
1177
|
|
|
1178
|
+
## 18. 插件管理(DSH 0.2.0-rc.2)
|
|
1179
|
+
|
|
1180
|
+
`hello.capabilities` 包含 `plugin-management-v1`。插件请求走控制连接,必须使用已配对设备 token;即使本机调试关闭了普通连接鉴权,匿名连接也不能执行这些请求。每个请求必须有非空 `requestId`,响应回显。`plugin-catalog` 的 `managementAvailable` 决定当前 Host profile 是否实际支持管理。
|
|
1181
|
+
|
|
1182
|
+
| 请求 type | 主要参数 | 成功响应 |
|
|
1183
|
+
|---|---|---|
|
|
1184
|
+
| `plugin-catalog` | `requestId` | 同名 kind;`managementAvailable`、`bundles`(每项含 `experimental`)、`plugins`、`officialItems`、`settingsWritable` |
|
|
1185
|
+
| `plugin-registries` | `requestId` | 同名 kind;`registry`、`fallbackRegistries`、`resolved` |
|
|
1186
|
+
| `plugin-inspect` | `requestId`, `spec`, `registry?` | `plugin-inspection`;`result` 为 accepted/refused 检查结果 |
|
|
1187
|
+
| `plugin-install` | `requestId`, `spec`, `enabled?`, `registry?`, `approvedBuilds?` | `plugin-install-result`;`result` 为 Host `ChangeResult` |
|
|
1188
|
+
| `plugin-install-status` / `plugin-install-cancel` | `requestId`, `installId` | 同名 kind;`result` 为安装结果/null 或取消状态 |
|
|
1189
|
+
| `plugin-bundle-set-enabled` | `requestId`, `name`, `enabled` | 同名 kind;`result` 为 `ChangeResult` |
|
|
1190
|
+
| `plugin-entry-set-enabled` | `requestId`, `entryId`, `enabled` | 同名 kind;`result` 为 `ChangeResult` |
|
|
1191
|
+
| `plugin-remove` | `requestId`, `name` | 同名 kind;`result` 为 `ChangeResult` |
|
|
1192
|
+
| `plugin-settings` | `requestId`, `ns?` | 同名 kind;全部 namespace 或指定的 `namespace` 视图 |
|
|
1193
|
+
| `plugin-settings-mutate` | `requestId`, `ns`, `expectedRevision`, `ops` | 同名 kind;返回修改后的 namespace 视图 |
|
|
1194
|
+
| `plugin-version-exemptions` / `plugin-version-exemption-set` | `requestId`;写入还需 `packageVersion`, `runtimeVersion`, `enabled`, `acceptRisk?` | 同名 kind;豁免列表或 `ChangeResult` |
|
|
1195
|
+
|
|
1196
|
+
Host 的 `plugin-manager/changed` 转为 `plugins-changed`(含 `reason`),`settings/document-updated` 转为 `plugin-settings-changed`(含 `ns`、`revision`)。`plugin-manager/install-state` 和 `plugin-manager/install-log` 只发给发起安装的连接,分别转为 `plugin-install-state` 和 `plugin-install-log`;它们的 `requestId` 就是原安装请求 ID。断线后用新的请求 ID 调 `plugin-install-status`,`installId` 填原安装 ID;切勿直接重复安装。关闭/卸载网关插件自身可能先断线,再由 DSH Web/CLI 恢复。
|
|
1197
|
+
|
|
1198
|
+
详细字段、官方/第三方页面分组、安装进度、配置 schema、错误处理和 DSH Mobile 验收步骤见 [插件管理对接方案](docs/dsh-0.2.0-rc.2-plugin-management-mobile-integration.md)。
|
|
1199
|
+
|
|
1170
1200
|
---
|
|
1171
1201
|
|
|
1172
1202
|
*协议与插件源码同源维护:`dsh-plugin-mobile-gateway/lib/index.mjs` 顶部注释即协议摘要。*
|
package/README.md
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
DeepSeek Harness 的设备鉴权移动网关,支持会话与实时事件、排队消息同步及编辑/删除/Steer、Session 归档和重命名的双向同步、停止当前生成并稍后继续、任务列表和当前 Goal 同步及管理、服务端驱动的命令和技能菜单、Human-in-the-loop、图片及文件传输。安装后,Harness WebUI 左侧边栏会出现“移动设备”入口,可直接开启网关、生成配对二维码和管理可信设备。
|
|
8
8
|
|
|
9
|
-
> 当前源码以 **DSH 0.
|
|
9
|
+
> 当前源码以 **DSH 0.2.0-rc.2** 为适配基线,使用 Session format 4;旧版历史游标需重新取得基线。
|
|
10
10
|
>
|
|
11
11
|
> 实时流已改为独立 `assistant-stream` 帧,移动端需要按 [rc.2 接入说明](docs/dsh-rc2-mobile-integration.md) 更新订阅、缓存与分页处理。当前修改尚未发布。
|
|
12
12
|
>
|
|
13
|
-
> v0.
|
|
13
|
+
> v0.9.0(当前源码):新增 DSH 0.2.0-rc.2 插件管理能力;PC 端可在面板开启、重启 Cloudflare Quick / 命名 Tunnel,使用插件私有缓存中的 `cloudflared` 并校验文件;配对面板增加局域网与外网入口选择指引;工具菜单可重启网关或停止 DSH Web,重启时不再打开新标签页。
|
|
14
14
|
>
|
|
15
15
|
> v0.7.3:优化移动网关运行模式下拉框的箭头间距。
|
|
16
16
|
>
|
|
@@ -18,7 +18,7 @@ DeepSeek Harness 的设备鉴权移动网关,支持会话与实时事件、排
|
|
|
18
18
|
|
|
19
19
|
## 协议与 DSH 兼容层
|
|
20
20
|
|
|
21
|
-
移动端连接的是本项目维护的 `dsh-mobile-v1`,不是 DSH 的内部 Remote 协议。插件内部通过独立 Host Adapter 对接 DSH 0.
|
|
21
|
+
移动端连接的是本项目维护的 `dsh-mobile-v1`,不是 DSH 的内部 Remote 协议。插件内部通过独立 Host Adapter 对接 DSH 0.2.0-rc.2 的 Remote Gateway;Session、Workspace、Settings、Commands、Goals、Schedule、Permission Presets、Plugin Manager 等 namespace 和严格参数只存在于该适配层。插件管理的请求帧及 DSH Mobile 页面方案见 [RC2 插件管理对接方案](docs/dsh-0.2.0-rc.2-plugin-management-mobile-integration.md)。
|
|
22
22
|
|
|
23
23
|
配对鉴权和 `dsh-mobile-v1` / `hello.protocol = 3` 保持不变。新版实时 token 不占用持久事件的 `seq`:客户端显式订阅 `assistantStream: true`,接收原子的 `session-snapshot` 和独立增量;普通 `event` 只携带持久事件。未接入新订阅的客户端只能收到持久消息。
|
|
24
24
|
|
|
@@ -195,6 +195,8 @@ http://127.0.0.1:<本地端口>
|
|
|
195
195
|
|
|
196
196
|
Quick Tunnel 无需 Cloudflare 账户、Token 或自己的公网域名,但地址在 DSH 重启或隧道重连后可能变化。手机 App 只信任已确认的地址,因此地址变化后需在面板重新生成二维码并扫码更新配对;**仅凭 PC 在线不能保证旧地址继续可用**。Cloudflare 将 Quick Tunnel 定位于测试和开发,当前有并发请求上限且不提供可用性保证。参见 [Cloudflare Quick Tunnel 文档](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/)。
|
|
197
197
|
|
|
198
|
+
面板中的“更新 Tunnel 配置”保存所选模式及命名 Tunnel 参数;配置不变时不会重新连接。“重启 Tunnel”会断开当前移动连接并重建 Cloudflare 隧道,不会重启 DSH Web;Quick Tunnel 重启后可能取得新地址,旧二维码会清除,需按新地址重新扫码。此操作不会自动升级 `cloudflared` 到最新版本。
|
|
199
|
+
|
|
198
200
|
### Cloudflare 命名 Tunnel(固定地址)
|
|
199
201
|
|
|
200
202
|
1. 在 [Cloudflare 控制台创建远程管理的 Tunnel](https://developers.cloudflare.com/tunnel/get-started/),添加一个公开域名(例如 `gateway.example.com`),将该路由的 **Service URL** 设为 `http://127.0.0.1:3082`。无需安装 Cloudflare 系统服务,也无需开放路由器入站端口。
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# DSH 0.2.0-rc.2 插件管理:DSH Mobile 对接方案
|
|
2
|
+
|
|
3
|
+
## 范围与入口
|
|
4
|
+
|
|
5
|
+
网关通过现有 `dsh-mobile-v1` WebSocket 提供与 DSH Web 插件页相同的管理数据和操作。所有请求发往 **control** 连接;单连接旧客户端也可发送。必须使用已配对设备的 token。`hello.capabilities` 包含 `plugin-management-v1` 表示网关认识这些帧,页面仍以 `plugin-catalog.managementAvailable` 判断当前 Host 能否管理插件。网关修改的是其所连接的 DSH profile,不是手机本地插件。
|
|
6
|
+
|
|
7
|
+
截图中的「添加插件」对应**安装已有插件包**,不是创建源码工程。DSH RC2 的 Host 没有远程生成插件工程的接口;本方案覆盖 Web 插件页的浏览、安装、启停、卸载、配置与诊断,以及 Host 公开的插件条目控制和版本豁免。
|
|
8
|
+
|
|
9
|
+
## 页面数据
|
|
10
|
+
|
|
11
|
+
连接后发送:
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{"type":"plugin-catalog","requestId":"catalog-1"}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
成功返回 `kind: plugin-catalog`、相同 `requestId`,以及:
|
|
18
|
+
|
|
19
|
+
| 字段 | 含义 |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `managementAvailable` | 当前 Host profile 是否支持插件管理;false 时隐藏写操作 |
|
|
22
|
+
| `bundles` | Host `pluginManager/listBundles` 数组;每项额外增加网关计算的 `experimental` 布尔值 |
|
|
23
|
+
| `plugins` | Host `pluginManager/listPlugins` 原始数组;用于包内条目及独立条目控制 |
|
|
24
|
+
| `officialItems` | Web 的四类内置配置卡片:`shell`、`agent-loop`、`subagent`、`web-search`;每项提供当前可用的 `namespaces` |
|
|
25
|
+
| `settingsWritable` | Host 设置是否允许写入 |
|
|
26
|
+
|
|
27
|
+
按 Web 页面语义展示:先排除 DSH 内置 profile 包 `@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-acp-app`、`@deepseek-ai/dsh-sdk-minimal`,再保留 `installed || optional || error` 的 bundle。其中 `optional && !installed` 放在「官方」,`installed || !optional` 放在「已安装」。内置配置卡片也放在「官方」,但没有包级启停开关,点击进入配置。`bundles` 每行有 `name`、`version?`、`meta?`、`description?`、`enabled`、`installed`、`optional`、`removable`、`readOnlyReason?`、`error?`、`rows`、`overrides`,以及网关追加的 `experimental`。DSH Web 根据包名以 `@deepseek-ai/dsh-experimental-` 开头来生成「实验性」标签;移动端直接用 `experimental: true` 显示本地化标签,不需要自行猜测。显示名称、说明和图标优先取 `meta` 的本地化字段;`meta.title` 和 `meta.description` 可以是文本或带 `en` 回退的语言映射,`meta.icon` 是图片 data URL,不作为 HTML 注入。否则回退到 `description` / `name`。`readOnlyReason` 禁用启停,`removable: false` 仅禁用卸载;官方可选包即使不可卸载仍可启停。不要用 `plugins` 推测一个 bundle 的总开关状态,以 `bundle.enabled` 为准。
|
|
28
|
+
|
|
29
|
+
`managementAvailable: false` 时返回空目录和 `settingsWritable: false`。所有目录数据属于所连网关的 DSH profile;切换网关后重新请求并按 `gatewayId` 分别缓存。
|
|
30
|
+
|
|
31
|
+
## 请求与响应
|
|
32
|
+
|
|
33
|
+
每个插件请求都必须带本次连接内唯一的非空 `requestId`;响应原样带回。成功帧如下,`result` 均为 Host 原始结果,App 不应只凭发送成功就更新开关:
|
|
34
|
+
|
|
35
|
+
| 请求 `type` | 参数 | 成功 `kind` 与字段 |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| `plugin-catalog` | — | `plugin-catalog`:上述目录字段 |
|
|
38
|
+
| `plugin-registries` | — | `plugin-registries`:`registry`、`fallbackRegistries`、`resolved` |
|
|
39
|
+
| `plugin-inspect` | `spec`, `registry?` | `plugin-inspection`:`result` 为 `accepted` 或 `refused` 的安装前检查 |
|
|
40
|
+
| `plugin-install` | `spec`, `enabled?`, `registry?`, `approvedBuilds?` | `plugin-install-result`:`result` 为 `ChangeResult` |
|
|
41
|
+
| `plugin-install-status` | `installId` | `plugin-install-status`:`result` 为 `ChangeResult` 或 `null` |
|
|
42
|
+
| `plugin-install-cancel` | `installId` | `plugin-install-cancel`:`result.status` 为 `cancelled` / `too-late` / `not-running` |
|
|
43
|
+
| `plugin-bundle-set-enabled` | `name`, `enabled` | 同名 `kind`:`result` 为 `ChangeResult` |
|
|
44
|
+
| `plugin-entry-set-enabled` | `entryId`, `enabled` | 同名 `kind`:`result` 为 `ChangeResult` |
|
|
45
|
+
| `plugin-remove` | `name` | 同名 `kind`:`result` 为 `ChangeResult` |
|
|
46
|
+
| `plugin-settings` | `ns?` | 同名 `kind`:不传 `ns` 返回 `writable`、`hasDocument`、`namespaces`;传 `ns` 返回 `writable`、`namespace` |
|
|
47
|
+
| `plugin-settings-mutate` | `ns`, `expectedRevision`, `ops` | 同名 `kind`:返回修改后的 namespace 视图 |
|
|
48
|
+
| `plugin-version-exemptions` | — | 同名 `kind`:`exemptions`、`warnings` |
|
|
49
|
+
| `plugin-version-exemption-set` | `packageVersion`, `runtimeVersion`, `enabled`, `acceptRisk?` | 同名 `kind`:`result` 为 `ChangeResult` |
|
|
50
|
+
|
|
51
|
+
`spec` 是 DSH/pnpm 安装规格,可为包名、绝对本地路径、Git 或 tarball。`registry` 为 HTTP(S) 地址或 `null`(使用 pnpm 自身配置),省略则按 Host 当前配置。移动端先调用 `plugin-registries` 提供来源选择,再 `plugin-inspect` 展示包名、版本、来源、是否 bundle;只有 `status: accepted` 才提交安装。包内插件条目使用 `entryId`,包级操作使用 `name`,二者不可互换。
|
|
52
|
+
|
|
53
|
+
### 安装与恢复
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{"type":"plugin-inspect","requestId":"inspect-1","spec":"some-dsh-plugin"}
|
|
57
|
+
{"type":"plugin-install","requestId":"install-1","spec":"some-dsh-plugin"}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
安装期间,同一控制连接收到:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{"kind":"plugin-install-state","requestId":"install-1","phase":"installing","attempt":{"registry":null,"index":1,"total":1}}
|
|
64
|
+
{"kind":"plugin-install-log","requestId":"install-1","jobId":"...","stream":"stdout","text":"..."}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`phase` 可为 `installing`、`cancelling`、`applying`。日志只发给发起安装的连接,包含 `jobId`、`stream`、`text` 和可选 `exitCode`;App 限制内存中日志行数,并对输出视为不可信文本。结束时收到 `plugin-install-result`。取消使用 `plugin-install-cancel`,其 `installId` 等于原安装请求的 `requestId`。断线重连后可发送 `plugin-install-status`,`installId` 仍为原安装 ID;它可能等待运行中的安装结束,`result: null` 表示 Host 无此记录,随后刷新目录。不要用新的 `plugin-install` 盲目重试。
|
|
68
|
+
|
|
69
|
+
`ChangeResult.application` 可能是 `applied`、`restart-required`、`overridden`、`failed`、`cancelled`;还需查看 `changed`、`stage`、`error`、`warnings`、`pendingBuilds`、`approvedBuilds`、`packageResult`。`restart-required` 时提示重启 DSH Web 后再刷新目录。若 `pendingBuilds` 非空,只有用户明确批准这些具体包的构建脚本,才再次安装并将包名放入 `approvedBuilds`;切勿自动批准。失败或取消后刷新目录确认最终状态。
|
|
70
|
+
|
|
71
|
+
### 开关、卸载与配置
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{"type":"plugin-bundle-set-enabled","requestId":"toggle-1","name":"some-dsh-plugin","enabled":false}
|
|
75
|
+
{"type":"plugin-entry-set-enabled","requestId":"entry-1","entryId":"some-entry-id","enabled":true}
|
|
76
|
+
{"type":"plugin-remove","requestId":"remove-1","name":"some-dsh-plugin"}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
用户操作前用目录的 `readOnlyReason`、`removable` 决定控件可用性。收到结果后依 `application` 展示成功、重启要求或错误,再刷新 `plugin-catalog`。**关闭或卸载 `dsh-plugin-mobile-gateway` 自身会切断当前连接**;App 应在操作前明确提示,并把连接中断后的最终状态标为“待重连确认”,不可视作操作失败或成功。关闭后只能从 DSH Web/CLI 重新开启。
|
|
80
|
+
|
|
81
|
+
配置卡片与包内配置都走 Host settings。先请求 `plugin-settings`,用 `namespace.schema` 渲染表单,展示 `value` / `base` / `user`,使用 `secrets[].set` 显示“已设置”,不得期待服务端回传密钥明文。保存时仅提交改动路径:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{"type":"plugin-settings-mutate","requestId":"save-1","ns":"web-search-deepseek","expectedRevision":4,"ops":[{"op":"set","path":["provider"],"value":"example"}]}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`ops` 支持 `set`(需要 `value`)和 `unset`,`path` 为字符串数组,空数组表示整个配置节。移动端应优先提交字段级操作并带读取时的 `revision`;版本冲突重新获取 namespace,让用户决定如何合并。内置配置卡片的 namespace 由 `officialItems` 返回,不要把 Web UI 内部路由当成插件 ID。
|
|
88
|
+
|
|
89
|
+
版本豁免属于高风险高级设置;只在用户看到 Host 的具体不兼容版本并明确接受风险时传 `acceptRisk: true`。普通插件页可以先只展示豁免情况,但协议已经支持读取与修改。
|
|
90
|
+
|
|
91
|
+
## 更新通知、错误与安全
|
|
92
|
+
|
|
93
|
+
| 服务端通知 | App 行为 |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `plugins-changed`,含 `reason` (`plugin` / `bundle` / `install` / `remove`) | 重新请求 `plugin-catalog`;该通知不包含完整目录 |
|
|
96
|
+
| `plugin-settings-changed`,含 `ns`、`revision` | 当前正在编辑该 namespace 时标记远端已更新,重新读取后再保存 |
|
|
97
|
+
| `plugin-install-state` / `plugin-install-log` | 仅更新匹配原安装 `requestId` 的进度 UI |
|
|
98
|
+
|
|
99
|
+
错误统一为 `{"kind":"error","requestType":"...","requestId":"...","code":"...","message":"..."}`。参数错误为 `bad-request`,未配对为 `authentication-required`,同 ID 安装进行中为 `plugin-install/in-progress`;Host 错误保留其 `code`。`plugin-inspect` 的业务拒绝是成功帧内 `result.status: refused`,不是传输错误。所有可执行操作只发至经过鉴权的控制连接,App 不应在未配对、只读或目录不可用时展示可用按钮。
|
|
100
|
+
|
|
101
|
+
## DSH Mobile 实施顺序与验收
|
|
102
|
+
|
|
103
|
+
1. 在协议模型和控制通道请求器加入上表帧;`requestId` 用 UUID,按 `(gatewayId, requestId)` 关联响应及安装事件,设置超时和断线恢复。
|
|
104
|
+
2. 完成插件页:官方可选包、四个内置配置卡片、已安装第三方包;使用 Host 提供的本地化元数据、状态与只读原因。
|
|
105
|
+
3. 完成「添加插件」流程:registries → inspect → 用户确认 → install → 进度/日志 → 结果/刷新;支持取消和重连查询。
|
|
106
|
+
4. 完成包级启停、条目级启停、卸载和 schema 配置;对 `restart-required`、构建脚本批准、网关自身停用及配置冲突给出明确状态。
|
|
107
|
+
5. 在已配对 RC2 环境验收:官方包开关、第三方包安装和卸载、配置读取/保存、安装取消、断线恢复;同时验证未配对控制连接被拒绝。
|
|
108
|
+
|
|
109
|
+
网关源码的单元测试覆盖 Host Remote 参数映射、响应、校验及安装 ID;端到端的真实包安装仍应在非生产 DSH profile 中验收,避免修改用户当前运行的 Web profile。
|
package/lib/client.js
CHANGED
|
@@ -352,6 +352,22 @@ window.__ModuleLoader__.load({
|
|
|
352
352
|
}
|
|
353
353
|
}
|
|
354
354
|
|
|
355
|
+
const restartCloudflare = async () => {
|
|
356
|
+
if (cloudflareBusy) return
|
|
357
|
+
setCloudflareBusy(true)
|
|
358
|
+
setError(null)
|
|
359
|
+
try {
|
|
360
|
+
const data = await request('/mgw/cloudflare/restart', { method: 'POST' })
|
|
361
|
+
if (data.mode === 'quick') setQr(null)
|
|
362
|
+
setStatus((current) => ({ ...(current || {}), cloudflare: data }))
|
|
363
|
+
await refresh()
|
|
364
|
+
} catch (cause) {
|
|
365
|
+
setError(cause.message)
|
|
366
|
+
} finally {
|
|
367
|
+
setCloudflareBusy(false)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
355
371
|
const createPairing = async (endpoint) => request('/mgw/pair', {
|
|
356
372
|
method: 'POST',
|
|
357
373
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -563,7 +579,10 @@ window.__ModuleLoader__.load({
|
|
|
563
579
|
onClick: () => configureCloudflare(true),
|
|
564
580
|
}, cloudflareBusy ? '正在处理…' : status.cloudflare.enabled && cloudflareMode === status.cloudflare.mode ? '更新 Tunnel 配置' : cloudflareMode === 'quick' ? '一键开启 Quick Tunnel' : '开启命名 Tunnel'),
|
|
565
581
|
status.cloudflare.enabled
|
|
566
|
-
? React.createElement(
|
|
582
|
+
? React.createElement(React.Fragment, null,
|
|
583
|
+
React.createElement('button', { style: { ...styles.button, width: '100%', marginTop: 8 }, disabled: cloudflareBusy || !status.gatewayEnabled, onClick: restartCloudflare }, '重启 Tunnel'),
|
|
584
|
+
React.createElement('button', { style: { ...styles.button, width: '100%', marginTop: 8 }, disabled: cloudflareBusy, onClick: () => configureCloudflare(false) }, '关闭 Cloudflare Tunnel'),
|
|
585
|
+
React.createElement('p', { style: styles.muted }, '重启会短暂断开手机连接;Quick Tunnel 可能获得新地址,需要重新扫码。'))
|
|
567
586
|
: null,
|
|
568
587
|
React.createElement('div', { style: { ...styles.muted, marginTop: 9, overflowWrap: 'anywhere', color: status.cloudflare.error ? colors.danger : colors.muted } },
|
|
569
588
|
status.cloudflare.error || ({ disabled: '未开启', starting: '正在启动本机入口…', preparing: '正在准备 cloudflared(首次需下载)…', connecting: '正在连接 Cloudflare…', online: '已连接 Cloudflare', 'waiting-for-gateway': '等待移动网关开启' }[status.cloudflare.state] || status.cloudflare.state),
|
|
@@ -263,6 +263,20 @@ export function createCloudflareTunnel({ file, port = 3082, wsPath, executable =
|
|
|
263
263
|
return snapshot()
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
+
const restart = () => {
|
|
267
|
+
queue = queue.catch(() => {}).then(async () => {
|
|
268
|
+
if (!shouldRun()) {
|
|
269
|
+
const cause = new Error('请先开启移动网关和 Cloudflare Tunnel')
|
|
270
|
+
cause.status = 409
|
|
271
|
+
throw cause
|
|
272
|
+
}
|
|
273
|
+
await stop()
|
|
274
|
+
await start()
|
|
275
|
+
return snapshot()
|
|
276
|
+
})
|
|
277
|
+
return queue
|
|
278
|
+
}
|
|
279
|
+
|
|
266
280
|
const dispose = () => { disposed = true; void stop() }
|
|
267
|
-
return { snapshot, configure, reconcile, dispose }
|
|
281
|
+
return { snapshot, configure, reconcile, restart, dispose }
|
|
268
282
|
}
|
package/lib/dsh-host-adapter.mjs
CHANGED
|
@@ -13,7 +13,7 @@ function requireArray(value, endpoint) {
|
|
|
13
13
|
return value
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
export const DSH_VERSION = '0.
|
|
16
|
+
export const DSH_VERSION = '0.2.0-rc.2'
|
|
17
17
|
export const SESSION_FORMAT_VERSION = 4
|
|
18
18
|
|
|
19
19
|
export function readHistoryRecords(records) {
|
|
@@ -247,12 +247,39 @@ export function createDshHostAdapter(typertGateway) {
|
|
|
247
247
|
},
|
|
248
248
|
settings: {
|
|
249
249
|
describe: (_payload = {}, signal) => invoke('settings', 'describe', {}, signal),
|
|
250
|
+
mutate: (payload, signal) => invoke('settings', 'mutate', {
|
|
251
|
+
ns: payload.ns,
|
|
252
|
+
ops: payload.ops,
|
|
253
|
+
expectedRevision: payload.expectedRevision,
|
|
254
|
+
}, signal),
|
|
250
255
|
update: (payload, signal) => invoke('settings', 'update', {
|
|
251
256
|
ns: payload.ns,
|
|
252
257
|
patch: payload.patch,
|
|
253
258
|
...(payload.expectedRevision === undefined ? {} : { expectedRevision: payload.expectedRevision }),
|
|
254
259
|
}, signal),
|
|
255
260
|
},
|
|
261
|
+
plugins: {
|
|
262
|
+
inventory: (_payload = {}, signal) => invoke('pluginInventory', 'list', {}, signal),
|
|
263
|
+
listBundles: (signal) => invoke('pluginManager', 'listBundles', {}, signal),
|
|
264
|
+
listPlugins: (signal) => invoke('pluginManager', 'listPlugins', {}, signal),
|
|
265
|
+
registries: (signal) => invoke('pluginManager', 'registries', {}, signal),
|
|
266
|
+
inspect: (spec, options, signal) => invoke('pluginManager', 'inspect', {
|
|
267
|
+
spec, ...(options === undefined ? {} : { options }),
|
|
268
|
+
}, signal),
|
|
269
|
+
installBundle: (spec, options) => invoke('pluginManager', 'installBundle', { spec, options }),
|
|
270
|
+
waitForInstall: (requestId) => invoke('pluginManager', 'waitForInstall', { requestId }),
|
|
271
|
+
cancelInstall: (requestId) => invoke('pluginManager', 'cancelInstall', { requestId }),
|
|
272
|
+
setBundleEnabled: (name, enabled) => invoke('pluginManager', 'setBundleEnabled', { name, enabled }),
|
|
273
|
+
setPluginEnabled: (id, enabled) => invoke('pluginManager', 'setPluginEnabled', { id, enabled }),
|
|
274
|
+
removeBundle: (name) => invoke('pluginManager', 'removeBundle', { name }),
|
|
275
|
+
listVersionExemptions: () => invoke('pluginManager', 'listVersionExemptions', {}),
|
|
276
|
+
setVersionExemption: (packageVersion, runtimeVersion, enabled, acceptRisk) => invoke(
|
|
277
|
+
'pluginManager', 'setVersionExemption', {
|
|
278
|
+
packageVersion, runtimeVersion, enabled,
|
|
279
|
+
...(acceptRisk === undefined ? {} : { acceptRisk }),
|
|
280
|
+
},
|
|
281
|
+
),
|
|
282
|
+
},
|
|
256
283
|
permissionPresets: {
|
|
257
284
|
catalog: (_payload = {}, signal) => invoke('permissionPresets', 'catalog', {}, signal),
|
|
258
285
|
},
|
package/lib/index.mjs
CHANGED
|
@@ -68,6 +68,8 @@ import { stringifyWireFrame } from './wire-json.mjs'
|
|
|
68
68
|
// { "type": "goal", "sessionId" } -> current goal projection
|
|
69
69
|
// { "type": "goal-edit", "sessionId", "ref", "objective"?, "maxGoalRounds"? }
|
|
70
70
|
// { "type": "goal-pause"|"goal-resume"|"goal-clear", "sessionId", "ref" }
|
|
71
|
+
// { "type": "plugin-catalog"|"plugin-inspect"|"plugin-install"|"plugin-settings"|..., "requestId", ... }
|
|
72
|
+
// -> paired control connection; see PROTOCOL.md section 18
|
|
71
73
|
// { "type": "agent-presets" } -> preset roster (+ isDefault)
|
|
72
74
|
// { "type": "defaults" } -> default agent preset + default permission
|
|
73
75
|
// { "type": "set-default", "target": "agent-preset"|"permission", "value" }
|
|
@@ -75,7 +77,7 @@ import { stringifyWireFrame } from './wire-json.mjs'
|
|
|
75
77
|
// { "type": "question-cancel", "rpcId", "sessionId" }
|
|
76
78
|
// { "type": "approval-response", "rpcId", "sessionId", "approvalId",
|
|
77
79
|
// "outcome": "allowed-once"|"rejected" }
|
|
78
|
-
// server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images", "commands", "tasks", "goals", "session-cancel", "queue-control", "session-archive", "session-rename", "file-downloads"],
|
|
80
|
+
// server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images", "commands", "tasks", "goals", "plugin-management-v1", "session-cancel", "queue-control", "session-archive", "session-rename", "file-downloads"],
|
|
79
81
|
// "authenticated", "device"?, "port", "clients" }
|
|
80
82
|
// { "kind": "pong", "at" }
|
|
81
83
|
// { "kind": "subscribed", "sessionId" }
|
|
@@ -130,6 +132,7 @@ import Schema from '@deepseek-ai/schemastery'
|
|
|
130
132
|
import { WebSocketServer } from 'ws'
|
|
131
133
|
import devicesModule from './devices.js'
|
|
132
134
|
import { createDshHostAdapter, DSH_VERSION, SESSION_FORMAT_VERSION } from './dsh-host-adapter.mjs'
|
|
135
|
+
import { handlePluginQuery, PLUGIN_REQUEST_TYPES } from './plugin-management.mjs'
|
|
133
136
|
import { createSessionFollower } from './session-follower.mjs'
|
|
134
137
|
import { createGatewayState, GATEWAY_MODES } from './gateway-state.mjs'
|
|
135
138
|
import { createCloudflareTunnel } from './cloudflare-tunnel.mjs'
|
|
@@ -1335,7 +1338,8 @@ function parseAgentPreset(msg, requestType, required = false) {
|
|
|
1335
1338
|
}
|
|
1336
1339
|
|
|
1337
1340
|
// Dispatch one mobile query frame; returns the wire frame to send back.
|
|
1338
|
-
async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
1341
|
+
async function handleQuery(api, host, agentDefaultModel, msg, pluginContext) {
|
|
1342
|
+
if (PLUGIN_REQUEST_TYPES.has(msg.type)) return handlePluginQuery(api, msg, pluginContext)
|
|
1339
1343
|
if (msg.type === 'session-agent-preset' || msg.type === 'select-agent-preset') {
|
|
1340
1344
|
const session = requireSessionId(msg)
|
|
1341
1345
|
const preset = parseAgentPreset(msg, msg.type, msg.type === 'select-agent-preset')
|
|
@@ -2196,6 +2200,7 @@ const plugin = {
|
|
|
2196
2200
|
},
|
|
2197
2201
|
})
|
|
2198
2202
|
const clients = new Set()
|
|
2203
|
+
const pluginInstallOwners = new Map()
|
|
2199
2204
|
const pendingQuestions = new Map()
|
|
2200
2205
|
const pendingApprovals = new Map()
|
|
2201
2206
|
const fileTransfers = createFileTransferManager(api, options)
|
|
@@ -2722,6 +2727,9 @@ const plugin = {
|
|
|
2722
2727
|
if (body.enabled && gatewayMode !== 'persistent') setGatewayMode('persistent', 'Cloudflare Tunnel enabled from management UI')
|
|
2723
2728
|
await tunnel.reconcile()
|
|
2724
2729
|
sendJson(res, 200, { ...tunnel.snapshot(), gatewayEnabled, gatewayMode })
|
|
2730
|
+
} else if (req.method === 'POST' && p === '/mgw/cloudflare/restart') {
|
|
2731
|
+
await tunnel.restart()
|
|
2732
|
+
sendJson(res, 200, { ...tunnel.snapshot(), gatewayEnabled, gatewayMode })
|
|
2725
2733
|
} else if (req.method === 'POST' && p === '/mgw/auth') {
|
|
2726
2734
|
const body = await readBody(req)
|
|
2727
2735
|
if (typeof body.enabled !== 'boolean') throw badRequest('enabled must be a boolean')
|
|
@@ -2944,8 +2952,23 @@ const plugin = {
|
|
|
2944
2952
|
msg.type === 'tasks' || msg.type === 'goal' || msg.type === 'goal-edit' ||
|
|
2945
2953
|
msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear' ||
|
|
2946
2954
|
msg.type === 'schedule-catalog' || msg.type === 'schedule-list' || msg.type === 'schedule-history' ||
|
|
2947
|
-
msg.type === 'schedule-update' || msg.type === 'schedule-delete'
|
|
2948
|
-
|
|
2955
|
+
msg.type === 'schedule-update' || msg.type === 'schedule-delete' ||
|
|
2956
|
+
PLUGIN_REQUEST_TYPES.has(msg.type)) {
|
|
2957
|
+
if (PLUGIN_REQUEST_TYPES.has(msg.type) && !ws.deviceId) {
|
|
2958
|
+
sendFrame({ kind: 'error', code: 'authentication-required', requestType: msg.type,
|
|
2959
|
+
...(typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}),
|
|
2960
|
+
message: 'Plugin management requires a paired device' })
|
|
2961
|
+
return
|
|
2962
|
+
}
|
|
2963
|
+
if (msg.type === 'plugin-install' && pluginInstallOwners.has(msg.requestId)) {
|
|
2964
|
+
sendFrame({ kind: 'error', code: 'plugin-install/in-progress', requestType: msg.type,
|
|
2965
|
+
requestId: msg.requestId, message: 'An installation already uses this requestId' })
|
|
2966
|
+
return
|
|
2967
|
+
}
|
|
2968
|
+
handleQuery(api, host, agentDefaultModel, msg, {
|
|
2969
|
+
onInstallStart: (requestId) => pluginInstallOwners.set(requestId, ws),
|
|
2970
|
+
onInstallEnd: (requestId) => pluginInstallOwners.delete(requestId),
|
|
2971
|
+
}).then((frame) => {
|
|
2949
2972
|
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2950
2973
|
})
|
|
2951
2974
|
} else {
|
|
@@ -2991,6 +3014,7 @@ const plugin = {
|
|
|
2991
3014
|
'tasks',
|
|
2992
3015
|
'goals',
|
|
2993
3016
|
'schedule-management',
|
|
3017
|
+
'plugin-management-v1',
|
|
2994
3018
|
'session-cancel',
|
|
2995
3019
|
'queue-control',
|
|
2996
3020
|
'session-archive',
|
|
@@ -3111,6 +3135,26 @@ const plugin = {
|
|
|
3111
3135
|
if (clients.size > 0) broadcastSessionMetadataFrame({ kind: 'schedule-changed' })
|
|
3112
3136
|
})
|
|
3113
3137
|
|
|
3138
|
+
const disposePluginsChanged = ctx.on('plugin-manager/changed', (change) => {
|
|
3139
|
+
if (clients.size > 0) broadcastSessionMetadataFrame({ kind: 'plugins-changed', reason: change?.reason })
|
|
3140
|
+
})
|
|
3141
|
+
const disposePluginSettingsChanged = ctx.on('settings/document-updated', (ns, revision) => {
|
|
3142
|
+
if (clients.size > 0) broadcastSessionMetadataFrame({ kind: 'plugin-settings-changed', ns, revision })
|
|
3143
|
+
})
|
|
3144
|
+
const sendPluginInstallEvent = (kind, event) => {
|
|
3145
|
+
const owner = pluginInstallOwners.get(event?.requestId)
|
|
3146
|
+
if (owner?.readyState === 1) owner.send(stringifyWireFrame({ kind, ...event }))
|
|
3147
|
+
}
|
|
3148
|
+
const disposePluginInstallState = ctx.on('plugin-manager/install-state', (progress) => {
|
|
3149
|
+
sendPluginInstallEvent('plugin-install-state', progress)
|
|
3150
|
+
})
|
|
3151
|
+
const disposePluginInstallLog = ctx.on('plugin-manager/install-log', (chunk) => {
|
|
3152
|
+
if (!chunk?.requestId) return
|
|
3153
|
+
const { requestId, jobId, stream, text, exitCode } = chunk
|
|
3154
|
+
sendPluginInstallEvent('plugin-install-log', { requestId, jobId, stream, text,
|
|
3155
|
+
...(exitCode === undefined ? {} : { exitCode }) })
|
|
3156
|
+
})
|
|
3157
|
+
|
|
3114
3158
|
const disposeQuestions = ctx.on('user-questions/request', (request, next) => {
|
|
3115
3159
|
const sessionId = request?.agent?.id === undefined ? null : String(request.agent.id)
|
|
3116
3160
|
if (!sessionId || !Array.isArray(request.questions) || !hasInteractionClient(sessionId)) return next()
|
|
@@ -3349,6 +3393,10 @@ const plugin = {
|
|
|
3349
3393
|
disposeMgmt()
|
|
3350
3394
|
disposeEvents()
|
|
3351
3395
|
disposeScheduleChanged()
|
|
3396
|
+
disposePluginsChanged()
|
|
3397
|
+
disposePluginSettingsChanged()
|
|
3398
|
+
disposePluginInstallState()
|
|
3399
|
+
disposePluginInstallLog()
|
|
3352
3400
|
disposeQuestions()
|
|
3353
3401
|
disposeApprovals()
|
|
3354
3402
|
if (lanServer) lanServer.close()
|
|
@@ -3358,6 +3406,7 @@ const plugin = {
|
|
|
3358
3406
|
client.terminate()
|
|
3359
3407
|
}
|
|
3360
3408
|
clients.clear()
|
|
3409
|
+
pluginInstallOwners.clear()
|
|
3361
3410
|
wss.close()
|
|
3362
3411
|
log('plugin stopped, all sockets closed')
|
|
3363
3412
|
})
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
const OFFICIAL_CONFIGURATION_ITEMS = Object.freeze([
|
|
2
|
+
{ id: 'shell', namespaces: ['bash-sandbox', 'pwsh-sandbox'] },
|
|
3
|
+
{ id: 'agent-loop', namespaces: ['agent-loop'] },
|
|
4
|
+
{ id: 'subagent', namespaces: ['subagent', 'subagent-model-selection-settings'] },
|
|
5
|
+
{ id: 'web-search', namespaces: ['web-search-deepseek'] },
|
|
6
|
+
])
|
|
7
|
+
|
|
8
|
+
export const PLUGIN_REQUEST_TYPES = new Set([
|
|
9
|
+
'plugin-catalog',
|
|
10
|
+
'plugin-registries',
|
|
11
|
+
'plugin-inspect',
|
|
12
|
+
'plugin-install',
|
|
13
|
+
'plugin-install-status',
|
|
14
|
+
'plugin-install-cancel',
|
|
15
|
+
'plugin-bundle-set-enabled',
|
|
16
|
+
'plugin-entry-set-enabled',
|
|
17
|
+
'plugin-remove',
|
|
18
|
+
'plugin-settings',
|
|
19
|
+
'plugin-settings-mutate',
|
|
20
|
+
'plugin-version-exemptions',
|
|
21
|
+
'plugin-version-exemption-set',
|
|
22
|
+
])
|
|
23
|
+
|
|
24
|
+
const FLAT_RESPONSE_TYPES = new Set([
|
|
25
|
+
'plugin-catalog', 'plugin-registries', 'plugin-settings',
|
|
26
|
+
'plugin-settings-mutate', 'plugin-version-exemptions',
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
function record(value) {
|
|
30
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function nonempty(value, maxLength = 2048) {
|
|
34
|
+
return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function badRequest(type, requestId, message) {
|
|
38
|
+
return { kind: 'error', code: 'bad-request', message, requestType: type,
|
|
39
|
+
...(requestId === undefined ? {} : { requestId }) }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function registryOption(msg, type, requestId) {
|
|
43
|
+
if (msg.registry === undefined) return { value: undefined }
|
|
44
|
+
if (msg.registry === null || (nonempty(msg.registry) && /^https?:\/\//i.test(msg.registry))) {
|
|
45
|
+
return { value: msg.registry }
|
|
46
|
+
}
|
|
47
|
+
return { error: badRequest(type, requestId, 'registry must be null or an HTTP(S) URL') }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function requireName(msg, field, type, requestId) {
|
|
51
|
+
if (!nonempty(msg[field], 512)) return { error: badRequest(type, requestId, `${field} must be a non-empty string`) }
|
|
52
|
+
return { value: msg[field].trim() }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function pluginCatalog(api) {
|
|
56
|
+
const inventory = await api.plugins.inventory()
|
|
57
|
+
if (!record(inventory) || !Array.isArray(inventory.entries)) throw new Error('pluginInventory/list returned an invalid inventory')
|
|
58
|
+
if (inventory.managementAvailable !== true) {
|
|
59
|
+
return { managementAvailable: false, bundles: [], plugins: [], officialItems: [], settingsWritable: false }
|
|
60
|
+
}
|
|
61
|
+
const [bundles, plugins, settings] = await Promise.all([
|
|
62
|
+
api.plugins.listBundles(),
|
|
63
|
+
api.plugins.listPlugins(),
|
|
64
|
+
api.settings.describe(),
|
|
65
|
+
])
|
|
66
|
+
if (!Array.isArray(bundles) || !Array.isArray(plugins) || !Array.isArray(settings?.namespaces)) {
|
|
67
|
+
throw new Error('plugin manager returned an invalid catalog')
|
|
68
|
+
}
|
|
69
|
+
const available = new Set(settings.namespaces.map(item => item.ns))
|
|
70
|
+
const officialItems = OFFICIAL_CONFIGURATION_ITEMS
|
|
71
|
+
.filter(item => item.namespaces.some(ns => available.has(ns)))
|
|
72
|
+
.map(item => ({ id: item.id, namespaces: item.namespaces.filter(ns => available.has(ns)) }))
|
|
73
|
+
return {
|
|
74
|
+
managementAvailable: true,
|
|
75
|
+
bundles: bundles.map(bundle => ({
|
|
76
|
+
...bundle,
|
|
77
|
+
experimental: bundle.name.startsWith('@deepseek-ai/dsh-experimental-'),
|
|
78
|
+
})),
|
|
79
|
+
plugins,
|
|
80
|
+
officialItems,
|
|
81
|
+
settingsWritable: settings.writable === true,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The mobile wire owns its request names and correlation ids. The Host adapter
|
|
87
|
+
* owns Typert endpoint names and argument descriptors.
|
|
88
|
+
*/
|
|
89
|
+
export async function handlePluginQuery(api, msg, { onInstallStart, onInstallEnd } = {}) {
|
|
90
|
+
const type = msg?.type
|
|
91
|
+
if (!PLUGIN_REQUEST_TYPES.has(type)) return null
|
|
92
|
+
const requestId = nonempty(msg.requestId, 128) ? msg.requestId.trim() : undefined
|
|
93
|
+
if (!requestId) return badRequest(type, undefined, `${type} requires a requestId`)
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
let value
|
|
97
|
+
let kind = type
|
|
98
|
+
if (type === 'plugin-catalog') {
|
|
99
|
+
value = await pluginCatalog(api)
|
|
100
|
+
} else if (type === 'plugin-registries') {
|
|
101
|
+
value = await api.plugins.registries()
|
|
102
|
+
} else if (type === 'plugin-inspect' || type === 'plugin-install') {
|
|
103
|
+
const spec = requireName(msg, 'spec', type, requestId)
|
|
104
|
+
if (spec.error) return spec.error
|
|
105
|
+
const registry = registryOption(msg, type, requestId)
|
|
106
|
+
if (registry.error) return registry.error
|
|
107
|
+
if (type === 'plugin-inspect') {
|
|
108
|
+
value = await api.plugins.inspect(spec.value, registry.value === undefined ? undefined : { registry: registry.value })
|
|
109
|
+
kind = 'plugin-inspection'
|
|
110
|
+
} else {
|
|
111
|
+
if (msg.enabled !== undefined && typeof msg.enabled !== 'boolean') {
|
|
112
|
+
return badRequest(type, requestId, 'enabled must be a boolean')
|
|
113
|
+
}
|
|
114
|
+
if (msg.approvedBuilds !== undefined && (!Array.isArray(msg.approvedBuilds)
|
|
115
|
+
|| msg.approvedBuilds.length > 100 || msg.approvedBuilds.some(name => !nonempty(name, 512)))) {
|
|
116
|
+
return badRequest(type, requestId, 'approvedBuilds must be an array of package names')
|
|
117
|
+
}
|
|
118
|
+
const options = {
|
|
119
|
+
requestId,
|
|
120
|
+
...(msg.enabled === undefined ? {} : { enabled: msg.enabled }),
|
|
121
|
+
...(registry.value === undefined ? {} : { registry: registry.value }),
|
|
122
|
+
...(msg.approvedBuilds === undefined ? {} : { approvedBuilds: msg.approvedBuilds }),
|
|
123
|
+
}
|
|
124
|
+
onInstallStart?.(requestId)
|
|
125
|
+
try {
|
|
126
|
+
value = await api.plugins.installBundle(spec.value, options)
|
|
127
|
+
} finally {
|
|
128
|
+
onInstallEnd?.(requestId)
|
|
129
|
+
}
|
|
130
|
+
kind = 'plugin-install-result'
|
|
131
|
+
}
|
|
132
|
+
} else if (type === 'plugin-install-status' || type === 'plugin-install-cancel') {
|
|
133
|
+
const installId = requireName(msg, 'installId', type, requestId)
|
|
134
|
+
if (installId.error) return installId.error
|
|
135
|
+
value = type === 'plugin-install-status'
|
|
136
|
+
? await api.plugins.waitForInstall(installId.value)
|
|
137
|
+
: await api.plugins.cancelInstall(installId.value)
|
|
138
|
+
} else if (type === 'plugin-bundle-set-enabled' || type === 'plugin-entry-set-enabled') {
|
|
139
|
+
const field = type === 'plugin-bundle-set-enabled' ? 'name' : 'entryId'
|
|
140
|
+
const target = requireName(msg, field, type, requestId)
|
|
141
|
+
if (target.error) return target.error
|
|
142
|
+
if (typeof msg.enabled !== 'boolean') return badRequest(type, requestId, 'enabled must be a boolean')
|
|
143
|
+
value = type === 'plugin-bundle-set-enabled'
|
|
144
|
+
? await api.plugins.setBundleEnabled(target.value, msg.enabled)
|
|
145
|
+
: await api.plugins.setPluginEnabled(target.value, msg.enabled)
|
|
146
|
+
} else if (type === 'plugin-remove') {
|
|
147
|
+
const name = requireName(msg, 'name', type, requestId)
|
|
148
|
+
if (name.error) return name.error
|
|
149
|
+
value = await api.plugins.removeBundle(name.value)
|
|
150
|
+
} else if (type === 'plugin-settings') {
|
|
151
|
+
const described = await api.settings.describe()
|
|
152
|
+
if (!record(described) || !Array.isArray(described.namespaces)) throw new Error('settings/describe returned an invalid response')
|
|
153
|
+
if (msg.ns !== undefined) {
|
|
154
|
+
const ns = requireName(msg, 'ns', type, requestId)
|
|
155
|
+
if (ns.error) return ns.error
|
|
156
|
+
const namespace = described.namespaces.find(item => item.ns === ns.value)
|
|
157
|
+
if (!namespace) return { kind: 'error', code: 'plugin-settings/not-found',
|
|
158
|
+
message: `settings namespace is unavailable: ${ns.value}`, requestType: type, requestId }
|
|
159
|
+
value = { writable: described.writable === true, namespace }
|
|
160
|
+
} else {
|
|
161
|
+
value = described
|
|
162
|
+
}
|
|
163
|
+
} else if (type === 'plugin-settings-mutate') {
|
|
164
|
+
const ns = requireName(msg, 'ns', type, requestId)
|
|
165
|
+
if (ns.error) return ns.error
|
|
166
|
+
if (!Number.isSafeInteger(msg.expectedRevision) || msg.expectedRevision < 0) {
|
|
167
|
+
return badRequest(type, requestId, 'expectedRevision must be a non-negative integer')
|
|
168
|
+
}
|
|
169
|
+
if (!Array.isArray(msg.ops) || msg.ops.length < 1 || msg.ops.length > 100
|
|
170
|
+
|| msg.ops.some(op => !record(op) || !['set', 'unset'].includes(op.op)
|
|
171
|
+
|| !Array.isArray(op.path) || op.path.some(part => !nonempty(part, 256))
|
|
172
|
+
|| (op.op === 'set' && !Object.hasOwn(op, 'value')))) {
|
|
173
|
+
return badRequest(type, requestId, 'ops must contain valid set or unset path edits')
|
|
174
|
+
}
|
|
175
|
+
value = await api.settings.mutate({ ns: ns.value, ops: msg.ops, expectedRevision: msg.expectedRevision })
|
|
176
|
+
} else if (type === 'plugin-version-exemptions') {
|
|
177
|
+
value = await api.plugins.listVersionExemptions()
|
|
178
|
+
} else if (type === 'plugin-version-exemption-set') {
|
|
179
|
+
const packageVersion = requireName(msg, 'packageVersion', type, requestId)
|
|
180
|
+
const runtimeVersion = requireName(msg, 'runtimeVersion', type, requestId)
|
|
181
|
+
if (packageVersion.error || runtimeVersion.error) return packageVersion.error || runtimeVersion.error
|
|
182
|
+
if (typeof msg.enabled !== 'boolean' || (msg.acceptRisk !== undefined && typeof msg.acceptRisk !== 'boolean')) {
|
|
183
|
+
return badRequest(type, requestId, 'enabled and acceptRisk must be booleans')
|
|
184
|
+
}
|
|
185
|
+
value = await api.plugins.setVersionExemption(packageVersion.value, runtimeVersion.value,
|
|
186
|
+
msg.enabled, msg.acceptRisk)
|
|
187
|
+
}
|
|
188
|
+
return { kind, requestId, ...(FLAT_RESPONSE_TYPES.has(type) && record(value)
|
|
189
|
+
? value : { result: value }) }
|
|
190
|
+
} catch (error) {
|
|
191
|
+
return { kind: 'error', code: error?.code || 'internal',
|
|
192
|
+
message: error?.message || String(error), requestType: type, requestId }
|
|
193
|
+
}
|
|
194
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-mobile-gateway",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Authenticated mobile gateway for DeepSeek Harness with
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Authenticated mobile gateway for DeepSeek Harness with realtime session sync, device pairing, plugin management, and LAN or Cloudflare Tunnel access",
|
|
5
5
|
"main": "lib/index.mjs",
|
|
6
6
|
"files": [
|
|
7
7
|
"bin",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"bin": "bin/setup-ip.mjs",
|
|
28
28
|
"scripts": {
|
|
29
|
-
"test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/session-preset.test.mjs && node test/session-follower.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/cloudflared-binary.test.mjs && node test/cloudflare-tunnel.test.mjs && node test/gateway-tools.test.mjs && node test/multi-gateway.test.mjs"
|
|
29
|
+
"test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/plugin-management.test.mjs && node test/session-preset.test.mjs && node test/session-follower.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/cloudflared-binary.test.mjs && node test/cloudflare-tunnel.test.mjs && node test/gateway-tools.test.mjs && node test/multi-gateway.test.mjs"
|
|
30
30
|
},
|
|
31
31
|
"exports": {
|
|
32
32
|
".": "./lib/index.mjs",
|