@omdp/dsh-connector 0.3.2 → 0.3.4
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/README.md +107 -13
- package/index.js +168 -36
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,12 +1,36 @@
|
|
|
1
1
|
# @omdp/dsh-connector
|
|
2
2
|
|
|
3
|
-
**MCP 服务器 + 用户 Skills + 魔搭市场浏览三合一设置页**(`v0.3.
|
|
3
|
+
**MCP 服务器 + 用户 Skills + 魔搭市场浏览三合一设置页**(`v0.3.4`)。适合需要在 DSH 里频繁增删改 MCP server / skills、又不想手改 `cordis.patch.yml` 的用户。
|
|
4
4
|
|
|
5
5
|
## Requirements
|
|
6
6
|
|
|
7
7
|
- DeepSeek Harness 带 `web` profile GUI(`npx @deepseek-ai/dsh web`)
|
|
8
8
|
- Node.js `^22.19` 或 `>=24`
|
|
9
|
-
- `@deepseek-ai/
|
|
9
|
+
- `@deepseek-ai/schemastery` `3.18.1` / `3.18.2` / `3.18.4`(peer 枚举,供工具过滤的 Config 声明用;无则过滤静默全放行)
|
|
10
|
+
- 已实测 DSH **0.1.5-rc.1**(源码级核查,2026-09-10)、**0.1.5-rc.2** 与 **0.1.6-alpha.1**(源码级 + 运行时冒烟:`/connector/api/*` 正常服务、设置页渲染,2026-09-15,见 `docs/plugin-compatibility.md`)、**0.1.7-rc.1**(0.3.3 适配:修掉 import 期崩溃 + 迁移到 settings 托管配置,2026-09-24)
|
|
11
|
+
- **`@deepseek-ai/dsh` peer 声明 `0.1.7-rc.1`**(0.3.4 起,逐版本枚举;规范见 `AGENTS.md` 规范 3)
|
|
12
|
+
|
|
13
|
+
## 兼容性门禁(0.3.4 起声明)
|
|
14
|
+
|
|
15
|
+
0.3.4 起本插件在 `peerDependencies` 里**显式声明支持的 DSH 版本**:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"@deepseek-ai/dsh": "0.1.7-rc.1"
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
这条声明由 DSH 自己的 **`evaluatePluginCompatibility()`**(`dsh-app-boot` 的公开导出,是一个 Inspector 可查的正式机制、不是本插件自造的约定)在**安装时**与**每次启动时**校验,语义是「声明版本 = 我实测过的版本」:
|
|
24
|
+
|
|
25
|
+
| 运行中的 DSH | 行为 |
|
|
26
|
+
|---|---|
|
|
27
|
+
| **`0.1.7-rc.1`**(本次实测) | ✅ `evaluatePluginCompatibility()` 返回 `undefined` ⇒ 正常加载,与 0.3.3 行为一致 |
|
|
28
|
+
| **`0.1.7` 及更新的、未实测版本** | ⛔ 门禁拦下:启动时**整个 bundle 被跳过**(stderr 打 `skipping profile bundle "@omdp/dsh-connector"`),安装时该行被置灰 |
|
|
29
|
+
| **`≤0.1.6` 及 `0.1.7-alpha.x`** | ➖ **不受影响**:那些版本里**根本没有这个门禁**(经解包 npm tarball 逐版核对,`0.1.5-rc.2`/`0.1.5-rc.3`/`0.1.6-alpha.1`/`0.1.6-alpha.2`/`0.1.7-alpha.1`/`0.1.7-alpha.2` 的 `dsh-app-boot` 里 `evaluatePluginCompatibility` 出现 **0 次**,只有 `0.1.7-rc.1` 出现 4 次),旧运行时读到这条 peer 只是「不认识的声明」,照常加载 |
|
|
30
|
+
|
|
31
|
+
**为什么故意只写 `0.1.7-rc.1`(而不是写老版本)**:老版本运行时压根不执行这个检查,写进去纯属装饰、无法被验证;而**未实测的新版本必须被拦住**——这正是 `AGENTS.md` 规范 3 的要求(`peerDependencies` 只精确枚举实测过的版本,禁止开放范围,未核查的版本宁可报 unmet peer 也不得预先放行)。门禁被触发时是**优雅跳过**(插件不加载、DSH 照常启动),符合本插件「硬保证 = DSH 不会因插件崩溃」的抗崩溃设计。
|
|
32
|
+
|
|
33
|
+
> 升级 DSH 后若设置页突然看不到 Connector 标签,先看启动日志有没有 `skipping profile bundle`——那就是门禁在提醒你「该插件尚未针对这个 DSH 版本做兼容核查」,核查通过后把该版本追加进上面的枚举并同步更新 `docs/plugin-compatibility.md`。
|
|
10
34
|
|
|
11
35
|
## Overview
|
|
12
36
|
|
|
@@ -14,7 +38,7 @@
|
|
|
14
38
|
合并到 DSH Web UI 的同一个设置页(设置页标签:**Connector**)。
|
|
15
39
|
|
|
16
40
|
- **MCP**:读取/编辑 `profiles/web/cordis.patch.yml` 中的 `mcp-*` 块(结构化表单)。保存后**重启 `dsh` 生效**。
|
|
17
|
-
- **工具过滤(0.3.0 新增)**:每台 MCP server 卡片下可勾选放行的工具(`mcp__<server>__<raw>` 公开名按 `__` 切分回 raw
|
|
41
|
+
- **工具过滤(0.3.0 新增)**:每台 MCP server 卡片下可勾选放行的工具(`mcp__<server>__<raw>` 公开名按 `__` 切分回 raw 名)。规则存本插件自己的配置(0.1.7+ 为 profile 条目 `connector` 行的 `config.toolFilters`;rc.x 为 `settings.yaml` 的 `connector.toolFilters`,双后端自动判别),**无配置 = 全量放行**;保存后新会话即生效、无需重启。生效三件套:`systemPrompt.tools(provider)` 隐藏 schema + `ctx.tools.guard` 执行期硬拦截(做法参照 `hyqhyq3/dsh-mcp-manager`)。典型场景:tinyfish 这类 15 个工具只留 `search`/`fetch_content` 两个免费工具。
|
|
18
42
|
- **Skills**:列出/查看/编辑/删除 `~/.dsh/skills` 下的 `SKILL.md`。保存**即时生效**(filesystem provider 自动重新发现)。
|
|
19
43
|
- **市场探索(0.2.0 新增)**:只读浏览魔搭社区 [Skills 中心](https://modelscope.cn/skills) 与 [MCP 广场](https://modelscope.cn/mcp)(匿名 OpenAPI,无需密钥)。
|
|
20
44
|
- 列表/详情:名称、作者、分类、下载/浏览数、认证标识(Hosted 官方托管 / 已认证)
|
|
@@ -81,7 +105,7 @@ pnpm install --lockfile-only --offline # 按 link 依赖重写 lockfile
|
|
|
81
105
|
|
|
82
106
|
```jsonc
|
|
83
107
|
"dependencies": {
|
|
84
|
-
"@omdp/dsh-connector": "^0.3.
|
|
108
|
+
"@omdp/dsh-connector": "^0.3.4"
|
|
85
109
|
}
|
|
86
110
|
```
|
|
87
111
|
|
|
@@ -144,7 +168,7 @@ pnpm remove @omdp/dsh-connector
|
|
|
144
168
|
- 列出当前 `cordis.patch.yml` 里的 `mcp-*` 服务器
|
|
145
169
|
- 「编辑」改名称/传输/URL/命令/参数/Header;「删除」移除;「+ 添加」新建
|
|
146
170
|
- 每台 server 卡片下有**工具过滤**多选(chips):勾选即放行,未勾选的工具模型不可见、调用被拒;「清除」回到全量放行
|
|
147
|
-
- 保存后提示**重启 dsh** 才会真正加载新的 MCP server
|
|
171
|
+
- 保存后提示**重启 dsh** 才会真正加载新的 MCP server(工具过滤规则除外:存**本插件的 profile 条目配置**,**新会话即生效**)
|
|
148
172
|
2. **Skills** 区:
|
|
149
173
|
- 列出 `~/.dsh/skills` 下的用户技能
|
|
150
174
|
- 「编辑」改 frontmatter 与正文;「删除」移除目录;「+ 新建」创建
|
|
@@ -163,7 +187,7 @@ pnpm remove @omdp/dsh-connector
|
|
|
163
187
|
| 设置页 | client half 注册 `settings.section` 槽位("Connector" 页签;client factory 须 `exports.inject = ['slots']`,否则 fiber 在 slots 就绪前跑 apply 会静默丢注册) |
|
|
164
188
|
| 跨边界调用 | client 用 `fetch('/connector/api/...')`,host 用 `ctx.webServer.register` 接收(安装包走 HTTP) |
|
|
165
189
|
| MCP 持久化 | 文本块级提取并替换 `cordis.patch.yml` 中含 `mcp-` 的 insert 块,**保留 `!!js` 表达式与 env 块原样**(preserve 桶) |
|
|
166
|
-
| 工具过滤 |
|
|
190
|
+
| 工具过滤 | 规则存本插件 profile 条目(`connector` 行)的 `config.toolFilters`,经 `Config` 的 `.volatile()` 字段由 DSH settings 服务托管(0.1.7+);`systemPrompt.tools(provider)` 滤 schema + `tools.guard` 硬拦截;`dsh-mcp-client` Config 封闭,规则**不能**写进 mcp 行 config |
|
|
167
191
|
| Skill 持久化 | 直接读写 `~/.dsh/skills/<name>/SKILL.md` |
|
|
168
192
|
|
|
169
193
|
## 已知限制
|
|
@@ -181,7 +205,9 @@ pnpm remove @omdp/dsh-connector
|
|
|
181
205
|
| 问题 | 原因 / 解决 |
|
|
182
206
|
|---|---|
|
|
183
207
|
| 设置页看不到 Connector 标签 | bundle 未挂载:确认 `dsh.profile.bundles` 含 `@omdp/dsh-connector`,重启 dsh;仍无则检查 client.js 尾部 `exports.inject = ['slots']` 是否在(缺了会静默丢注册) |
|
|
184
|
-
| 工具过滤不生效(模型仍能看到/调用) | 过滤规则只对**新会话**生效(当前会话的 schema
|
|
208
|
+
| 工具过滤不生效(模型仍能看到/调用) | 过滤规则只对**新会话**生效(当前会话的 schema 已下发);确认规则落盘位置正确——0.1.7+ 看 profile 条目 `connector` 的 `config.toolFilters`,rc.x 看 `~/.dsh/settings.yaml` 的 `connector.toolFilters`;再确认 serverName 拼写与 patch 一致 |
|
|
209
|
+
| 升级 0.1.7 后勾好的工具过滤变回全量放行 | 0.1.7 的配置迁移会静默漏掉未声明 volatile `Config` 的段,规则被留在 `~/.dsh/settings.yaml.imported` 里;0.3.4 的 `ensureLegacyFiltersMigration()` 会在首次请求时自动搬回(日志 `migrated toolFilters from settings.yaml.imported`)。**不要删 `settings.yaml.imported`**——它是遗留配置的唯一副本。若仍未恢复,用 `PUT /connector/api/mcp/filters` 手写一次即可 |
|
|
210
|
+
| 升级 DSH 后设置页整页丢失 Connector 标签、`/connector/api/*` 全 404 | 先查启动日志有无 `skipping profile bundle "@omdp/dsh-connector"`——这是 0.1.7 起的**兼容性门禁**拦下了尚未核查的 DSH 版本(见「## 兼容性门禁」)。核查通过后把该版本追加进 `peerDependencies["@deepseek-ai/dsh"]` 枚举 |
|
|
185
211
|
| 保存 MCP 被拒(HTTP 400) | 配置不合法(transport/serverName/command/url 校验失败),按提示修正——插件不会写入坏配置 |
|
|
186
212
|
| MCP server 保存后不生效 | 需**重启 dsh**(`dsh-mcp-client` 静态加载) |
|
|
187
213
|
| `/connector/api/*` 404 | client/host 边界异常:确认插件 host 半边已加载(重启),浏览器强刷缓存 |
|
|
@@ -228,9 +254,9 @@ MIT License。安全问题请通过 GitHub Issues 私密报告(https://github.
|
|
|
228
254
|
|
|
229
255
|
| 数据 | 访问方式 | 说明 |
|
|
230
256
|
|---|---|---|
|
|
231
|
-
| `profiles/web/cordis.patch.yml` | **读写** | MCP 块的结构化编辑(保留 `!!js`/env
|
|
257
|
+
| `profiles/web/cordis.patch.yml` | **读写** | MCP 块的结构化编辑(保留 `!!js`/env 原样);0.1.7+ 工具过滤规则也由 DSH settings 服务写回本行 `config.toolFilters` |
|
|
232
258
|
| `~/.dsh/skills/**/SKILL.md` | **读写** | 用户技能文件的查看/编辑/删除/新建;「记录来源」会写 `source`/`sourceUpdated` frontmatter |
|
|
233
|
-
| `~/.dsh/settings.yaml` 等 |
|
|
259
|
+
| `~/.dsh/settings.yaml` 等 | rc.x 经 settings 服务读;0.1.7+ **只读** `settings.yaml.imported` 做一次性救援 | 0.1.7+ 不再直接读写活动配置(DSH 已改为 profile 条目托管);`.imported` 是遗留配置唯一副本,**永不删除** |
|
|
234
260
|
| HTTP `/connector/api/*` | 本机监听 | 与 DSH GUI 同源,无额外鉴权 |
|
|
235
261
|
| 魔搭 `modelscope.cn/openapi/v1` | **只读外部** | 市场浏览代理(匿名);结果仅存进程内存(30 分钟 TTL),**不写文件、不落盘** |
|
|
236
262
|
| 环境变量 | 只读引用 | 只读 `process.env.*`,不持久化 |
|
|
@@ -241,10 +267,44 @@ MIT License。安全问题请通过 GitHub Issues 私密报告(https://github.
|
|
|
241
267
|
|
|
242
268
|
本插件采用**抗崩溃架构**,DSH 更新时不会导致 DSH 崩溃(硬保证)。
|
|
243
269
|
|
|
244
|
-
- **纯静态依赖**:只 `import node:*` + `yaml
|
|
270
|
+
- **纯静态依赖**:只 `import node:*` + `yaml`(`^2.9.0`);`jsdom`(`^24.1.3`)**只在魔搭 WAF 挑战求解时动态 `import()`**,不在模块顶层加载(见下方 0.1.7 说明)。`@deepseek-ai/schemastery` 仅 peer 声明(供工具过滤的 Config 声明用),且用 `typeof field.volatile === 'function'` 守卫,具备则走 0.1.7+ 托管配置。
|
|
245
271
|
- **唯一的 DSH 硬依赖**:`ctx.webServer`(`inject: ['webServer']`),用于注册 `/connector/api/*` HTTP 路由。
|
|
246
272
|
- **失败隔离**:webServer 不可用/变化时插件**干净失败不加载**,DSH 照常运行;内部多处 try/catch 防御。
|
|
247
273
|
|
|
274
|
+
### 为什么 jsdom 必须懒加载(0.3.3 修复)
|
|
275
|
+
|
|
276
|
+
0.3.2 在模块顶层 `import { JSDOM, VirtualConsole } from 'jsdom'`,这会在插件 import 期就拉起
|
|
277
|
+
`jsdom → whatwg-url → tr46` 依赖图,而 `tr46/index.js` 第 3 行是 `require("punycode/")`。
|
|
278
|
+
DSH `0.1.7-rc.1` 的解析路由(`dsh-app-boot` `ResolutionRouter.routeScoped`)对 `punycode/`
|
|
279
|
+
这种**带子路径的内置模块名**会先切出裸名 `punycode`,再用
|
|
280
|
+
`createRequire(...).resolve.paths("punycode")` 求查找路径 —— Node 对裸内置模块名返回
|
|
281
|
+
`null`,而该处循环没有兜底,直接抛:
|
|
282
|
+
|
|
283
|
+
```
|
|
284
|
+
TypeError: createRequire.resolve.paths is not a function or its return value is not iterable
|
|
285
|
+
at ResolutionRouter.routeScoped (…/dsh-app-boot/lib/index.js:1414:58)
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
后果不是"市场功能不可用",而是**整个插件的 import 失败**:fiber 建不起来,设置页那行永远停在
|
|
289
|
+
「已安装,重启后生效」,`/connector/api/*` 全部 404(重启也不会好,因为抛错是确定性的)。
|
|
290
|
+
修复即把 jsdom 挪进 `loadJsdom()`,只在真正要解 WAF 挑战时才 `await import('jsdom')`,
|
|
291
|
+
失败范围收敛到该功能本身。
|
|
292
|
+
|
|
293
|
+
> ⚠️ **诚实说明:这是缓解,不是根治。** DSH 的解析拦截(`PluginPackages` 安装的
|
|
294
|
+
> `installRuntimeInterception`)在**进程生命周期内常驻**(只在 `ctx.effect` 清理时 `dispose()`),
|
|
295
|
+
> 所以懒加载只是把同一个 `TypeError` **推迟到首次解 WAF 挑战时**,并非消除:
|
|
296
|
+
>
|
|
297
|
+
> | | 0.3.2(顶层静态 import) | 0.3.3(懒加载) |
|
|
298
|
+
> |---|---|---|
|
|
299
|
+
> | 插件 import | ❌ 崩溃 ⇒ 设置页死的、全部路由 404 | ✅ 干净 |
|
|
300
|
+
> | 设置页 / MCP / Skills / 工具过滤 | ❌ 全废 | ✅ 恢复 |
|
|
301
|
+
> | 魔搭市场浏览(走 WAF 解) | ❌(插件都没起来) | ⚠️ 首次解挑战仍会抛,但被 `marketError` 包成 **502 + 明确 message**,不挂起 |
|
|
302
|
+
> | 爆炸半径 | 整个插件 | 单个功能分支 |
|
|
303
|
+
>
|
|
304
|
+
> 实测该 WAF 挑战当前**未下发**(`PUT /api/v1/dolphin/mcpServers` 返回 HTTP 200、116103 字节、
|
|
305
|
+
> 无 `acw_sc__v2`/`aliyunwaf`),故该路径处于休眠。**根治仍应由 DSH 在 `dsh-app-boot` 的该循环
|
|
306
|
+
> 补 `?? []` 兜底**(同文件 `packageDirFromAnchor` 是有兜底的,新增 `routeScoped` 时漏了)。
|
|
307
|
+
|
|
248
308
|
| 场景 | 崩溃? |
|
|
249
309
|
|---|---|
|
|
250
310
|
| DSH 小更新/补丁 | ✅ 不会崩 |
|
|
@@ -252,6 +312,40 @@ MIT License。安全问题请通过 GitHub Issues 私密报告(https://github.
|
|
|
252
312
|
| yaml 版本 | ✅ 独立 npm 包,不受 DSH 更新影响 |
|
|
253
313
|
| 魔搭市场不可达 | ✅ 市场接口报 502,本地 MCP/skill 管理不受影响 |
|
|
254
314
|
|
|
255
|
-
**最后验证**:DSH `0.1.
|
|
256
|
-
|
|
257
|
-
|
|
315
|
+
**最后验证**:DSH `0.1.7-rc.1`(2026-09-24,本轮修复:模块 import 期零抛错,`node --check` + 实际
|
|
316
|
+
`import()` 冒烟通过;工具过滤读写改用 0.1.7 的 `Config`+`.volatile()`+`settings.replace()`;
|
|
317
|
+
0.3.4 追加 `@deepseek-ai/dsh` peer 声明并用 DSH 真实 `evaluatePluginCompatibility()` 验证门禁行为、
|
|
318
|
+
追加遗留 `toolFilters` 一次性救援)。历史:
|
|
319
|
+
DSH `0.1.0-rc.8`(2026-08-20);0.2.0 市场功能以 `node --check` + 真实 HTTP 集成测试通过(11 项:
|
|
320
|
+
skills/mcp 列表与详情、证书/Hosted 标识、安装命令、记录来源回写、更新判定),未改动 DSH 实例。
|
|
321
|
+
|
|
322
|
+
## 变更记录
|
|
323
|
+
|
|
324
|
+
- **0.3.4**(2026-09-24):**声明 DSH 版本支持 + 修复工具过滤被静默清空**。
|
|
325
|
+
1. **`@deepseek-ai/dsh` peer 声明 `0.1.7-rc.1`**(逐版本枚举,语义见上方「## 兼容性门禁」)。
|
|
326
|
+
用 DSH 真实的 `evaluatePluginCompatibility()` 逐一验证:`0.1.7-rc.1` → 正常加载;
|
|
327
|
+
`0.1.7-alpha.2` / `0.1.6-alpha.1` / `0.1.5-rc.3` / `0.1.8-rc.1` → 被门禁拦下(前者是老运行时
|
|
328
|
+
无门禁、后者是未实测的新版本,均符合规范 3)。
|
|
329
|
+
2. **修复工具过滤丢失(根因:DSH 0.1.7 的配置迁移是「全有或全无」)** —— 0.1.7 首次启动会把
|
|
330
|
+
`<DSH_HOME>/settings.yaml` 改名为 `settings.yaml.imported` 并逐段导入 profile 条目;该导入
|
|
331
|
+
对**没有 volatile `Config` 声明的段**会**静默跳过**(只往 stderr 打一行 `settings: section …
|
|
332
|
+
was not imported`)。本插件 0.3.3 才刚引入 `Config`,所以用户机器上 `connector.toolFilters`
|
|
333
|
+
被落在 `settings.yaml.imported` 里没搬过来 ⇒ 过滤规则读成空 ⇒ **全量放行**(表现为
|
|
334
|
+
「设置页里勾的过滤没了」)。
|
|
335
|
+
修复:新增 `ensureLegacyFiltersMigration()`,在**路由处理前**(`apply()` 期间 `settings.replace()`
|
|
336
|
+
不可用,因为该条目 fiber 尚未进入 ACTIVE 状态)检查一次——若活的 `config.toolFilters` 为空、
|
|
337
|
+
而 `settings.yaml.imported` 里还留着 `connector.toolFilters`,就把它写回本插件的 profile 条目
|
|
338
|
+
并打一行 `migrated toolFilters from settings.yaml.imported`。失败时复位重试标记,下一次请求再试。
|
|
339
|
+
一次性且幂等:已有非空过滤规则时**不覆盖用户当前设置**。
|
|
340
|
+
3. `settings.yaml.imported` **只读、绝不删除**——它是遗留配置的唯一副本(0.1.7 迁移后就地改名,
|
|
341
|
+
原始 `settings.yaml` 已不存在;DSH 内置导入器见到 `.imported` 不会再跑)。
|
|
342
|
+
|
|
343
|
+
- **0.3.3**(2026-09-24):**适配 DSH 0.1.7-rc.1**。
|
|
344
|
+
1. **修复 import 期崩溃**:`jsdom` 由顶层静态 import 改为按需 `await import()`(原因见上方
|
|
345
|
+
「为什么 jsdom 必须懒加载」)——0.3.2 在 0.1.7 上整个插件 import 失败、设置页永远显示
|
|
346
|
+
「已安装,重启后生效」、API 全 404。
|
|
347
|
+
2. **工具过滤迁到 0.1.7 托管配置**:导出 `Config = z.object({ toolFilters: <dict>.volatile() })`,
|
|
348
|
+
读取走注入的 `config.toolFilters` 活 Ref、写入走 `settings.replace(entryId, …)`;同时对
|
|
349
|
+
0.1.5/0.1.6 rc.x 保留老的 `settings.register/get/update` 后端(`typeof volatile === 'function'`
|
|
350
|
+
守卫自动分流),**新旧两代均可用**。
|
|
351
|
+
3. 补 `@deepseek-ai/schemastery` peer 枚举 `3.18.4`(0.1.7 自带版本)。
|
package/index.js
CHANGED
|
@@ -26,8 +26,16 @@ import { spawnSync } from 'node:child_process'
|
|
|
26
26
|
import { homedir } from 'node:os'
|
|
27
27
|
import { join, dirname } from 'node:path'
|
|
28
28
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
|
29
|
-
import { JSDOM, VirtualConsole } from 'jsdom'
|
|
30
29
|
import z from '@deepseek-ai/schemastery'
|
|
30
|
+
// NOTE: `jsdom` is deliberately NOT imported at module scope. A static import
|
|
31
|
+
// pulls in jsdom -> whatwg-url -> tr46, and tr46/index.js:3 does
|
|
32
|
+
// `require("punycode/")`. Under DSH 0.1.7's ResolutionRouter that subpath form
|
|
33
|
+
// resolves `createRequire(...).resolve.paths("punycode")` to null, which the
|
|
34
|
+
// router iterates without a guard (dsh-app-boot/lib/index.js:1414) and throws
|
|
35
|
+
// `TypeError: createRequire.resolve.paths is not a function or its return value
|
|
36
|
+
// is not iterable` — killing this whole plugin's import (fiber never created,
|
|
37
|
+
// UI shows "已安装,重启后生效"). Only the ModelScope WAF challenge needs jsdom,
|
|
38
|
+
// so it is loaded lazily there and the failure is local to that feature.
|
|
31
39
|
|
|
32
40
|
const API_PREFIX = '/connector/api'
|
|
33
41
|
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
@@ -41,12 +49,14 @@ const TRANSPORTS = new Set(['stdio', 'streamable-http'])
|
|
|
41
49
|
// patch file itself (NUL, control chars). Tab/newline are already rejected by
|
|
42
50
|
// the single-token rules; this catches the rest defensively.
|
|
43
51
|
const CONTROL_CHARS = /[\x00-\x1f\x7f]/
|
|
52
|
+
// 0.1.7 移除了 settings.register/get/update;`settings` 服务本身仍需存在(本插件的
|
|
53
|
+
// Config 条目才会进入组合树并被托管),且老版本(0.1.5/0.1.6 rc.x)仍走它的
|
|
54
|
+
// register/get/update 老 API,所以 inject 保留。
|
|
44
55
|
export const inject = ['webServer', 'settings']
|
|
45
56
|
|
|
46
57
|
// ── MCP 工具过滤(tool-filter, 0.3.0 新增)────────────────────────────
|
|
47
58
|
// dsh-mcp-client 的 Config schema 是封闭的:未知键会拒收并导致下次启动失败,
|
|
48
|
-
// 所以过滤规则不能写进 mcp-* 行的 config,只能放在 connector
|
|
49
|
-
// namespace(settings.yaml `connector:` 节)里:
|
|
59
|
+
// 所以过滤规则不能写进 mcp-* 行的 config,只能放在 connector 自己的配置里:
|
|
50
60
|
// connector:
|
|
51
61
|
// toolFilters:
|
|
52
62
|
// <serverName>:
|
|
@@ -56,25 +66,107 @@ export const inject = ['webServer', 'settings']
|
|
|
56
66
|
// 1. systemPrompt.tools(provider) —— 提示词里的 schema 列表先过滤,模型看不到被滤掉的工具;
|
|
57
67
|
// 2. ctx.tools.guard —— 执行期硬拦截(guard 返回 reason 即拒收),补 provider 漏网;
|
|
58
68
|
// 3. UI 多选框 —— 见 client.js ServerForm 的工具过滤区 + GET /api/mcp/tools/:serverName。
|
|
59
|
-
// 无配置 = 全量放行(零回归);guard
|
|
69
|
+
// 无配置 = 全量放行(零回归);guard 是同步函数,每次调用现读最新值。
|
|
70
|
+
//
|
|
71
|
+
// 配置后端是双版本的(同 dsh-key-fallback 的做法):
|
|
72
|
+
// · 0.1.7+:DSH 用 settings 服务托管插件配置 —— 本插件导出带 `.volatile()` 的
|
|
73
|
+
// `Config`,apply(ctx, config) 拿到的 config.toolFilters 是一个活 Ref,读写走
|
|
74
|
+
// `settings.replace(entryId, ...)`;
|
|
75
|
+
// · 0.1.5/0.1.6(rc.x):无 `.volatile()`,`Config` 退化为 undefined,改用老的
|
|
76
|
+
// `settings.register(ns, schema)` + `settings.get(ns)` + `settings.update(ns, ...)`。
|
|
77
|
+
// 两条路都在 apply 时判别一次,之后读写自动分流;都没有时静默全放行。
|
|
78
|
+
// Profile entry id of this plugin (the `id:` in the bundle's cordis.patch.yml,
|
|
79
|
+
// which the user's own profile patch keeps unless they override the row). The
|
|
80
|
+
// settings service addresses config by entry id, not by settings namespace.
|
|
60
81
|
const CONNECTOR_SETTINGS_NS = 'connector'
|
|
82
|
+
const CONNECTOR_ENTRY_ID = 'connector'
|
|
61
83
|
const ToolFilterSchema = z.object({
|
|
62
84
|
toolFilters: z.dict(z.object({ allow: z.array(String).default([]) })).default({}),
|
|
63
85
|
})
|
|
64
|
-
|
|
86
|
+
// DSH 0.1.7+ hands a `.volatile()` field to apply() as a live Ref; 0.1.6 and older
|
|
87
|
+
// lack `volatile`, so Config degrades to undefined and the legacy path is used.
|
|
88
|
+
const _toolFiltersField = z.dict(z.object({ allow: z.array(String).default([]) })).default({})
|
|
89
|
+
export const Config = (typeof _toolFiltersField.volatile === 'function')
|
|
90
|
+
? z.object({ toolFilters: _toolFiltersField.volatile() })
|
|
91
|
+
: undefined
|
|
92
|
+
// 后端状态:二选一(0.1.7+ 用 _filterRef;rc.x 用 _legacyScope/_legacySvc),都用空表示全放行。
|
|
93
|
+
let _filterRef = null // 0.1.7+: config.toolFilters 这个活 Ref
|
|
94
|
+
let _legacyScope = null // rc.x: settings.register(...) 返回的 scope
|
|
95
|
+
let _legacySvc = null // rc.x: settings 服务(读用 get,写用 update)
|
|
96
|
+
let _settingsSvc = null // 0.1.7+: ctx.settings(迁移写回用,见 ensureLegacyFiltersMigration)
|
|
97
|
+
let _migrationAttempted = false
|
|
98
|
+
function readToolFilters() {
|
|
65
99
|
try {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
100
|
+
// 优先 0.1.7+ 的活 Ref(.get() 每次返回最新值)
|
|
101
|
+
if (_filterRef) {
|
|
102
|
+
const value = _filterRef.get()
|
|
103
|
+
return normalizeToolFilters(value)
|
|
104
|
+
}
|
|
105
|
+
// rc.x:老的 settings.get(ns) —— 每次现读,外部编辑同样立即生效
|
|
106
|
+
if (_legacySvc && typeof _legacySvc.get === 'function') {
|
|
107
|
+
const value = _legacySvc.get(CONNECTOR_SETTINGS_NS)
|
|
108
|
+
return normalizeToolFilters(value && value.toolFilters)
|
|
74
109
|
}
|
|
75
|
-
return
|
|
110
|
+
return {}
|
|
76
111
|
} catch { return {} }
|
|
77
112
|
}
|
|
113
|
+
function normalizeToolFilters(filters) {
|
|
114
|
+
if (!filters || typeof filters !== 'object' || Array.isArray(filters)) return {}
|
|
115
|
+
const out = {}
|
|
116
|
+
for (const [server, rule] of Object.entries(filters)) {
|
|
117
|
+
const allow = rule && Array.isArray(rule.allow) ? rule.allow.filter((t) => typeof t === 'string' && t.length > 0) : []
|
|
118
|
+
out[server] = { allow }
|
|
119
|
+
}
|
|
120
|
+
return out
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* ── 0.1.7 迁移遗留救援 ──────────────────────────────────────────────────────
|
|
124
|
+
* DSH 0.1.7 启动时把 <home>/settings.yaml 改名为 settings.yaml.imported,再把每个
|
|
125
|
+
* section 当作对应 entry 的 config override 调 settings.update(ns, values) 写回。
|
|
126
|
+
* 而 update() 走 write(),要求该 entry 的 Config 含 volatile 字段,否则抛
|
|
127
|
+
* 「Config field ... is not volatile」,只打一条 warn 就跳过——配置静默留在
|
|
128
|
+
* .imported 里,插件再也看不到它(UI 显示「未配置 = 全量放行」,用户以为改动被吞)。
|
|
129
|
+
*
|
|
130
|
+
* 本插件 0.3.2 及更早没有 volatile 的 toolFilters,升级 0.1.7 时用户的过滤规则
|
|
131
|
+
* 就是这样丢的。0.3.3 补上了 volatile Config(写入通道已通),但已经丢过一次的
|
|
132
|
+
* 数据不会自己回来,所以这里做一次性救援:把滞留在 .imported 的 connector 节
|
|
133
|
+
* 搬进 profile entry 的 config(即 cordis.patch.yml 的 connector 行)。
|
|
134
|
+
*
|
|
135
|
+
* 时序:settings.replace() 要求该 entry 的 fiber 处于 ACTIVE(2)——apply() 期间
|
|
136
|
+
* 还没到,所以这里只在惰性调用(首次读 filters 的路由)里跑,绝不放进 apply()。
|
|
137
|
+
* 语义:只搬一次;本插件已有配置时不动(避免覆盖用户后来的修改);失败不抛错
|
|
138
|
+
* (GET 必须可用),留给下一次请求重试。
|
|
139
|
+
*/
|
|
140
|
+
async function ensureLegacyFiltersMigration() {
|
|
141
|
+
if (_migrationAttempted) return
|
|
142
|
+
_migrationAttempted = true
|
|
143
|
+
try {
|
|
144
|
+
if (!_filterRef || !_settingsSvc || typeof _settingsSvc.replace !== 'function') return
|
|
145
|
+
// 已有配置 → 用户在新后端上配过了,不要覆盖。
|
|
146
|
+
const existing = normalizeToolFilters(_filterRef.get())
|
|
147
|
+
if (Object.keys(existing).length > 0) return
|
|
148
|
+
// 读遗留文档:不存在 = 全新安装或已被内置导入器处理过,无事可做。
|
|
149
|
+
let text
|
|
150
|
+
try {
|
|
151
|
+
text = await readFile(join(resolveHome(), 'settings.yaml.imported'), 'utf8')
|
|
152
|
+
} catch {
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
let doc
|
|
156
|
+
try { doc = parseYaml(text) } catch { return }
|
|
157
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return
|
|
158
|
+
const block = doc[CONNECTOR_SETTINGS_NS]
|
|
159
|
+
const migrated = normalizeToolFilters(block && block.toolFilters)
|
|
160
|
+
if (Object.keys(migrated).length === 0) return
|
|
161
|
+
await _settingsSvc.replace(CONNECTOR_ENTRY_ID, { toolFilters: migrated })
|
|
162
|
+
console.info('[dsh-connector] migrated toolFilters from settings.yaml.imported:', Object.keys(migrated).join(', '))
|
|
163
|
+
} catch (error) {
|
|
164
|
+
// 写失败(entry 尚未 ACTIVE、revision 冲突等):清标记让下一次请求重试,
|
|
165
|
+
// 否则一次过早的调用会永久放弃救援。
|
|
166
|
+
_migrationAttempted = false
|
|
167
|
+
console.warn('[dsh-connector] toolFilters migration skipped:', error?.message ?? error)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
78
170
|
// 公开名 `mcp__<server>__<raw>` 反解回 (server, raw):注意 serverName 本身可含
|
|
79
171
|
// 下划线,所以按 `mcp__` 前缀 + __ 分段取“第一段”为 server,余下 join 回 raw。
|
|
80
172
|
function splitPublicName(publicName) {
|
|
@@ -540,8 +632,17 @@ let wafCookie = null // { value, at }
|
|
|
540
632
|
// Solve the acw_sc__v2 cookie by executing the challenge page in jsdom. The
|
|
541
633
|
// challenge HTML arrives as the body of the WAF-blocked request itself (the
|
|
542
634
|
// SPA shell on /mcp is NOT a challenge page and yields no cookie).
|
|
635
|
+
// jsdom is imported lazily on purpose — see the note next to the import list.
|
|
636
|
+
let _jsdom = null
|
|
637
|
+
async function loadJsdom() {
|
|
638
|
+
if (_jsdom) return _jsdom
|
|
639
|
+
const mod = await import('jsdom')
|
|
640
|
+
_jsdom = mod.default && mod.default.JSDOM ? mod.default : mod
|
|
641
|
+
return _jsdom
|
|
642
|
+
}
|
|
543
643
|
async function solveWafFromChallenge(html) {
|
|
544
644
|
if (wafCookie && Date.now() - wafCookie.at < WAF_TTL) return wafCookie.value
|
|
645
|
+
const { JSDOM, VirtualConsole } = await loadJsdom()
|
|
545
646
|
const vc = new VirtualConsole()
|
|
546
647
|
vc.on('jsdomError', () => {}) // jsdom navigation is not implemented — that is expected here
|
|
547
648
|
const dom = new JSDOM(html, {
|
|
@@ -922,23 +1023,38 @@ async function readBody(req) {
|
|
|
922
1023
|
}
|
|
923
1024
|
}
|
|
924
1025
|
|
|
925
|
-
export function apply(ctx) {
|
|
926
|
-
// ── 工具过滤三件套之 (0)
|
|
927
|
-
//
|
|
928
|
-
//
|
|
1026
|
+
export function apply(ctx, config) {
|
|
1027
|
+
// ── 工具过滤三件套之 (0):判定配置后端 + 快照缓存 ──
|
|
1028
|
+
// 0.1.7+:config.toolFilters 是 volatile Ref(.get() 每次返回最新值,编辑热生效)。
|
|
1029
|
+
// rc.x:无 volatile,退回老的 settings.register/get/update(0.3.0 的原始实现)。
|
|
1030
|
+
// 两者皆无 → 恒 {} 全放行(零回归)。
|
|
929
1031
|
// 注:cordis 的 ctx.inject(deps, cb) 回调按 (ctx, config) 调用,不是服务展开,
|
|
930
1032
|
// 不能拿回调参数当 service 用 —— 0.3.0 曾因此 register 从未落地。
|
|
931
|
-
const
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1033
|
+
const currentFilters = () => {
|
|
1034
|
+
try { return readToolFilters() } catch { return {} }
|
|
1035
|
+
}
|
|
1036
|
+
try {
|
|
1037
|
+
const field = config && config.toolFilters
|
|
1038
|
+
if (field && typeof field.get === 'function') {
|
|
1039
|
+
_filterRef = field
|
|
1040
|
+
_settingsSvc = ctx.get('settings') || null
|
|
1041
|
+
ctx.effect(() => () => { _filterRef = null; _settingsSvc = null }, 'connector: release tool filter ref')
|
|
1042
|
+
} else {
|
|
1043
|
+
// ── rc.x 老后端:注册 settings namespace(本插件自己的两段式配置)──
|
|
1044
|
+
const settings = ctx.get('settings')
|
|
1045
|
+
if (settings && typeof settings.register === 'function') {
|
|
1046
|
+
const scope = settings.register(CONNECTOR_SETTINGS_NS, ToolFilterSchema, { base: { toolFilters: {} } })
|
|
1047
|
+
_legacySvc = settings
|
|
1048
|
+
_legacyScope = scope
|
|
1049
|
+
ctx.effect(() => () => { _legacySvc = null; _legacyScope = null }, 'connector: release legacy tool filter scope')
|
|
1050
|
+
// scope 存在时用它的 watch 做热刷新(0.3.0 的行为);读仍走 settings.get。
|
|
1051
|
+
if (scope && typeof scope.watch === 'function') {
|
|
1052
|
+
ctx.effect(() => scope.watch(() => {}), 'connector: watch tool filters')
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
941
1055
|
}
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
console.error('[dsh-connector] tool filter config unavailable:', error?.message ?? error)
|
|
942
1058
|
}
|
|
943
1059
|
|
|
944
1060
|
// ── 三件套之 (1):prompt 层过滤 —— assemble waterfall 真替换 ──
|
|
@@ -950,7 +1066,7 @@ export function apply(ctx) {
|
|
|
950
1066
|
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
|
951
1067
|
const assembled = await next()
|
|
952
1068
|
if (!assembled || !Array.isArray(assembled.tools)) return assembled
|
|
953
|
-
const kept = assembled.tools.filter((t) => isToolAllowed(
|
|
1069
|
+
const kept = assembled.tools.filter((t) => isToolAllowed(currentFilters(), t && t.name))
|
|
954
1070
|
if (kept.length === assembled.tools.length) return assembled
|
|
955
1071
|
return { ...assembled, tools: kept }
|
|
956
1072
|
}, { global: true })
|
|
@@ -961,7 +1077,7 @@ export function apply(ctx) {
|
|
|
961
1077
|
if (tools && typeof tools.guard === 'function') {
|
|
962
1078
|
ctx.effect(() => tools.guard((exec) => {
|
|
963
1079
|
if (!exec || typeof exec.name !== 'string') return undefined
|
|
964
|
-
if (isToolAllowed(
|
|
1080
|
+
if (isToolAllowed(currentFilters(), exec.name)) return undefined
|
|
965
1081
|
const split = splitPublicName(exec.name)
|
|
966
1082
|
return `connector: 工具 ${exec.name} 已被过滤(server "${split ? split.server : '?'}” 的 allow 列表未包含它)`
|
|
967
1083
|
}), 'connector: mcp tool filter guard')
|
|
@@ -974,6 +1090,11 @@ export function apply(ctx) {
|
|
|
974
1090
|
try {
|
|
975
1091
|
if (!path.startsWith(API_PREFIX)) { res.writeHead(404); res.end(); return }
|
|
976
1092
|
|
|
1093
|
+
// 首次请求时救援 0.1.7 迁移遗留的 toolFilters(见 ensureLegacyFiltersMigration)。
|
|
1094
|
+
// 放在这里而不是 apply():settings.replace() 要求本 entry 已 ACTIVE。
|
|
1095
|
+
// 成功后 _migrationAttempted 短路,后续请求零开销。
|
|
1096
|
+
await ensureLegacyFiltersMigration()
|
|
1097
|
+
|
|
977
1098
|
// GET /api/mcp — list server entries (with preserve buckets)
|
|
978
1099
|
if (req.method === 'GET' && path === API_PREFIX + '/mcp') {
|
|
979
1100
|
let text
|
|
@@ -1058,14 +1179,14 @@ export function apply(ctx) {
|
|
|
1058
1179
|
} catch (error) {
|
|
1059
1180
|
return json(res, 500, { error: String(error?.message ?? error).slice(0, 200) })
|
|
1060
1181
|
}
|
|
1061
|
-
const filters = readToolFilters(
|
|
1182
|
+
const filters = readToolFilters()
|
|
1062
1183
|
const rule = filters[serverName]
|
|
1063
1184
|
return json(res, 200, { server: serverName, tools: names.sort(), allow: rule ? rule.allow : [] })
|
|
1064
1185
|
}
|
|
1065
1186
|
|
|
1066
1187
|
// GET /api/mcp/filters — 全部 server 的过滤规则(UI 批量展示用)
|
|
1067
1188
|
if (req.method === 'GET' && path === API_PREFIX + '/mcp/filters') {
|
|
1068
|
-
return json(res, 200, { filters: readToolFilters(
|
|
1189
|
+
return json(res, 200, { filters: readToolFilters() })
|
|
1069
1190
|
}
|
|
1070
1191
|
|
|
1071
1192
|
// PUT /api/mcp/filters — 保存过滤规则 → settings.yaml `connector:` 节
|
|
@@ -1092,13 +1213,24 @@ export function apply(ctx) {
|
|
|
1092
1213
|
}
|
|
1093
1214
|
try {
|
|
1094
1215
|
const settings = ctx.get('settings')
|
|
1095
|
-
if (
|
|
1096
|
-
|
|
1216
|
+
if (_filterRef) {
|
|
1217
|
+
// 0.1.7+:settings 服务托管配置。replace() 先清空本 entry 的全部 volatile
|
|
1218
|
+
// 字段再写入本节:toolFilters 是唯一 volatile 字段,所以等价于「整节覆盖」,
|
|
1219
|
+
// 空 allow 的 server 已被上面清掉。
|
|
1220
|
+
if (!settings || typeof settings.replace !== 'function') {
|
|
1221
|
+
return json(res, 503, { error: 'settings service unavailable (DSH 0.1.7+ required to persist tool filters)' })
|
|
1222
|
+
}
|
|
1223
|
+
await settings.replace(CONNECTOR_ENTRY_ID, { toolFilters: clean })
|
|
1224
|
+
} else if (_legacySvc && typeof _legacySvc.update === 'function') {
|
|
1225
|
+
// rc.x:老的命名空间写入(settings.yaml `connector:` 节)
|
|
1226
|
+
await _legacySvc.update(CONNECTOR_SETTINGS_NS, { toolFilters: clean })
|
|
1227
|
+
} else {
|
|
1228
|
+
return json(res, 503, { error: 'settings service unavailable; cannot persist tool filters' })
|
|
1229
|
+
}
|
|
1097
1230
|
} catch (error) {
|
|
1098
1231
|
return json(res, 500, { error: String(error?.message ?? error).slice(0, 300) })
|
|
1099
1232
|
}
|
|
1100
|
-
|
|
1101
|
-
return json(res, 200, { ok: true, filters: readToolFilters(ctx) })
|
|
1233
|
+
return json(res, 200, { ok: true, filters: readToolFilters() })
|
|
1102
1234
|
}
|
|
1103
1235
|
|
|
1104
1236
|
// GET /api/skills — list
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omdp/dsh-connector",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "Unified DeepSeek Harness connector: edit MCP servers (cordis.patch.yml), user skills (~/.dsh/skills) and browse the ModelScope MCP/Skills marketplaces from one Web UI settings page.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"jsdom": "^24.1.3"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@deepseek-ai/
|
|
49
|
+
"@deepseek-ai/dsh": "0.1.7-rc.1",
|
|
50
|
+
"@deepseek-ai/schemastery": "3.18.1 || 3.18.2 || 3.18.4"
|
|
50
51
|
}
|
|
51
52
|
}
|