lpf0404/dsh-compat-market ↗★ 0

dsh-compat-market

DSH 插件市场:浏览官方与社区插件,装之前先给出兼容性结论,一键交给官方安装管线。Plugin market for DeepSeek Harness: browse official and community DSH plugins with a compatibility verdict, and install them through the official plugin manager. 适合需要发现和安装社区插件的桌面端用户,提供安装前的兼容性检查。

パッケージ
dsh-compat-market
互換性
未検証
バージョン
0.2.0
ライセンス
MIT
最終更新
2026/10/03

インストール

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:lpf0404/dsh-compat-market

ドキュメント

README 全文を読む ↗

dsh-compat-market

为 DeepSeek Harness(DSH)桌面端打造的插件市场:在侧栏浏览官方与社区插件,装之前就看到兼容性结论,一键交给官方安装管线安装。


为什么再造一个市场

DSH 官方没有插件市场,但已经有一整套插件管理能力(@deepseek-ai/dsh-plugin-manager 服务 + 侧栏「插件」面板)。官方面板自己写明了一条限制:

「添加插件」接受包名(可带版本)、Git 地址、压缩包或本地绝对路径 —— 包名就是 README 里 dsh plugin add 后面的那一段

也就是说:官方面板只会「管理」,不会「发现」 —— 你得先知道包名才能装。而社区已有的几个市场全都绕开了官方安装管线,自己 clone 仓库、自己写 cordis.patch.yml。

本项目补的正是这条缝,并且只补这条缝:

能力官方面板其它社区市场本插件
发现社区插件✗ 需先知道包名✓✓
走官方安装管线(注册表回退 / 失败回滚 / 构建脚本授权)✓✗ 自行 clone✓
装之前给出兼容性结论✗ 装完才报 incompatible-version✗ 不检查✓
把社区信息注入原生插件详情页—✗✓
官方与社区同屏只看随装的官方包只看社区✓

它不做什么

不自建安装管线。安装、启用、卸载全部委托给 pluginManager,因此自动获得注册表按序回退、失败时回滚 package.json 与 pnpm-lock.yaml、pnpm 构建脚本授权,以及与 DSH 完全一致的兼容性预检。


安装

在 DSH 的「插件」面板里添加,或直接用 plugin_manager 工具:

plugin_manager { action: "install_bundle", target: "dsh-compat-market" }

安装会走官方管线:注册表按序回退、失败时回滚 package.json 与 pnpm-lock.yaml、需要构建脚本时向你请示。

安装后即时生效,无需重启 —— 当前 profile 的 hmr 行处于活动状态,官方管理器返回 application: "applied"。

从本地目录安装(开发用)

把 spec 传成本包的绝对路径:

plugin_manager { action: "install_bundle", target: "D:\\path\\to\\dsh-compat-market" }

本地路径会被装成 link: 依赖(Windows 上是 junction),因此改动本目录即刻生效。

注意(踩过的坑):pnpm 在 Windows 上建立的 node_modules 链接用的是绝对路径。所以移动或重命名这个检出目录之后,链接会全部悬空,插件会以一个非常费解的错误加载失败:ERR_MODULE_NOT_FOUND: Cannot find package 'semver' —— 看起来像依赖没装,实际上是路径变了。修复只需在这个目录里重跑一次 pnpm install(会从本地 store 复用,约 1 秒)。

顺带一条:清理 node_modules 时不要直接 Remove-Item -Recurse。pnpm 的 store 位于 D:\.pnpm-store,而树里有上千个指向它的硬链接和 151 个 junction,PowerShell 可能顺着 junction 递归进去。先逐个 cmd /c rmdir 摘掉 junction 再删,才是安全的。

边界的诚实说明:浏览器半边(lib/client.js)由 dsh-client-hmr 每 500 ms 轮询,改动会热替换,连页面刷新都不需要。Host 半边(lib/index.js)不会热重载(当前 profile 的 hmr.config.root: []),改动需重启 DSH 才生效。


兼容性门禁(核心价值)

市场在点击安装之前就给出结论。判据是 DSH 自己的预检函数 evaluatePluginCompatibility(@deepseek-ai/dsh-app-boot)的忠实移植:

  • 只检查名为 @deepseek-ai/dsh 或以 @deepseek-ai/dsh- 开头的 peer;@deepseek-ai/cordis 从不检查
  • workspace:^ / workspace:~ / workspace:* 视为「当前运行时」,恒满足
  • 空 range 视为不兼容
  • 其余用 semver.satisfies(runtime, range, { includePrerelease: true })

必须用真正的 semver 语义,不能用简化匹配:生态里出现过 278 种不同的 range 写法,包括 || 链和 `>=x

关闭 / 恢复匿名(默认就是这个状态)

DSH_MARKET_GITHUB_TOKEN=off


> **⚠️ 开启意味着什么,请先读完再决定**
>
> - **这个 token 会被发送给 GitHub**,每个请求都带 `Authorization: Bearer `,GitHub 端能看到调用者是这个 token 对应的账号。
> - **消耗的是这个 token 的配额,不是 token 本身。** token 不会被"用掉",被消耗的是它名下每小时 5000 次的调用额度;额度用尽后该 token 的所有调用(包括你自己在用的其他工具、CI)都会一起被限流到下一个整点。这也是为什么启用后仍保留 **200 次的余量**:这份配额很可能不只你在用。
> - 额度是按 token 计的,**不随插件卸载而恢复**;卸载插件不会撤销这个 token。
> - **在界面里保存的 token 会以明文落盘。** 位置是 `
/.plugin-market/settings.json`(`DSH_HOME` 下;Windows 上通常是 `C:\Users\\.dsh\profiles\desktop\.plugin-market\settings.json`),写入时请求权限 `0600`。**在 Windows 上 `0600` 基本是装饰性的** —— NTFS 仍按父目录继承 ACL,同机其他账号、以及任何以你身份运行的程序都能读到它。要"绝不落盘",就用方式二的环境变量,并点「清除」确保文件里不留副本。这是本次新增界面开关带来的真实代价,不是可以忽略的细节。

开启后自动生效的变化,不需要改任何配置:单轮预算 40 → **250**,余量 5 → **200**,扫描进度按配额自动推进,窗口 1000 条约 **4 次重建**走完(匿名模式约 6 天)。面板顶部会显示一行提示「已启用 GitHub token:请求会消耗该 token 的配额」——**只要它在发 token,就不会让你看不见**。

安全边界(都有测试钉住):

- **绝不自动读取环境里的 `GITHUB_TOKEN`/`GH_TOKEN`/`GITHUB_PAT`。** 只有点名给本插件的 `DSH_MARKET_GITHUB_TOKEN` 才会被使用 —— 一个"顺手把环境里那个 token 转发出去"的插件,会让一次例行重装变成凭据泄露。社区里确实有插件从本机 `gh` 凭据里捞 token(`dsh-plugin-radar`)或直接 shell 出 `gh auth token`(`dsw-workshop-plugin`),本插件不这么做。
- **只发给 `api.github.com`。** 认证头按解析后的主机名判断,其它主机(含 `api.github.com.evil.test` 这类仿冒域名)一律不发。
- **永不回传给浏览器。** token 不进入缓存状态、不进入 `/list` 摘要、不进入 `/health`,对外只暴露「有没有配置」这个布尔量。`/settings` 的响应体被断言过:整段响应文本里不允许出现 token,也不允许出现任何 `ghp_` 形状的字符串。
- **`/settings` 只接受本机调用。** 这个 HTTP 载体不做任何认证,所以设置接口的**读和写**都限制在 loopback —— 远程调用者既不能探出"这台机器上配没配 token",也不能替换或清除它。写设置必须是 POST,请求体上限 4KB(超了直接断开,不缓冲),字段类型不对一律 400 而不是被强转。
- **环境变量永远压过界面开关。** 面板会显示当前生效的是哪一层(`env` / `settings` / `default`);当环境变量说了算时,开关显示为禁用并注明原因。这样就不会出现"点了开关看着生效了、其实一直是环境变量在管事"。
- **设置改动在下次重建目录时生效**(快照 TTL 6h),点「刷新」可以立即生效。这里刻意**不做**"改一下立刻重建":那会让一次界面点击触发约 1500 次注册表请求。
- `DSH_MARKET_GITHUB_TOKEN=off`(或 `none`/`false`/空)一律视为匿名,绝不会把字面量 `off` 当成凭据发出去。存进设置文件的 token 走同一道归一化,所以粘贴一个 `off` 也不会变成凭据。

**GitHub 条目的结论是「临时」的**,描述的是**默认分支当前状态**而非固定发布,因此:

- verdict 带 `provisional: true`,界面上明确标注,不伪装成和 npm 条目同级
- 没有版本可固定,所以界面不显示「版本」,改显示源码仓库与 star
- 安装 spec 就是这个仓库地址,仍由官方 `pluginManager` 执行、仍跑官方预检
- **不做提交固定**:pnpm 取的是默认分支**当下**的状态,两次安装可能装到不同提交。安全上没有缺口(官方预检在实际取到的产物上再跑一次),但**可复现性弱于 npm 条目** —— 见「已知限制」

### 数据通道

- **目录**:Host 侧构建并缓存在 `
/.plugin-market/catalog.json`,经**同源** HTTP 前缀路由 `/api/plugin-market` 暴露只读接口。
  之所以用普通路由而不是 Typert Remote:安装已经走预生成的 `pluginManager` remote 命名空间,本插件唯一需要暴露给自己浏览器半边的只是只读目录 JSON,普通路由不需要 schema 代码生成,也不会因为 Typert 反射格式变动而加载失败。
- **安装**:浏览器半边直接调用 `ctx.remote.pluginManager.installBundle(...)`,不经过本插件的 Host 代码。

### 接口

| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/plugin-market/list` | `search` `tone` `kind` `official` `sort` `offset` `limit` |
| GET | `/api/plugin-market/detail?name=` | 完整条目(含 peer 明细) |
| GET | `/api/plugin-market/official` | 随 DSH 提供的官方组合包及其启用状态 |
| POST | `/api/plugin-market/refresh` | 强制重建(**仅回环地址**可用) |
| GET | `/api/plugin-market/health` | 运行时版本 / 注册表 / 缓存状态 |

### 性能(实测,真实 DSH 进程内)

| 场景 | 耗时 |
| --- | --- |
| 冷构建(1 571 候选 → 1 224 插件) | 约 22 s(受注册表限流影响),缓存命中后不再发生 |
| 面板打开(快照 TTL 6 小时内) | **754 ms** |
| 单页 6 条响应体 | 4.2 KB |

manifest 缓存以 `name@version` 为键并只保留所需字段(原始 manifest 全程缓存约 27 MB,投影后约 1.5 MB)。注册表返回 429/503 时会做一次退避重试;全部注册表都失败时**不会**用空目录覆盖已有快照,而是继续提供略旧的快照。

---

## 安装后的验收

实测于 `desktop` profile、DSH `0.2.0-rc.2`:

| 验收项 | 结果 |
| --- | --- |
| 官方管理器安装 | `changed: true`,`application: "applied"`,退出码 0 |
| 加载器行 | `include:plugin-market` → `enabled: true`,**`fiberPhase: "active"`** |
| Host 路由(进程内) | `GET /api/plugin-market/health` → 200,`runtimeVersion: "0.2.0-rc.2"`,注册表探测选中 `registry.npmmirror.com` |
| 实时目录 | `GET /list` → 200,1 140 个社区插件,218 个不兼容 |
| 侧栏面板 | `sidebar.panellist` 占用者:原生 `plugins`(order 0) + 本插件 `plugin-market`(order 20),均 `active: true` |
| 主面板 | `main` 键 `plugin-market`,`active: true` |
| 原生详情页注入 | `plugins.detail.badge` → `plugin-market.verdict`;`plugins.detail.section` → `plugin-market.section`,均 `active: true` |

前四项用官方 `plugin_manager` 工具与 HTTP 请求验证,后三项用**实时客户端**的 `cordis_inspect_query`(`client` / `Slots` / `listSubTree`)读取真实 Slot 树验证 —— 这直接证明浏览器半边被加载并完成了注册,而不只是「文件存在」。

---

## 开发

```bash
# 单元测试:兼容性门禁 + 服务核心 + 存储
node --test test/compat.test.mjs test/market.test.mjs test/store.test.mjs

# 插件装配与路由(不需要 DSH 运行)
node tools/test-plugin.mjs

# 浏览器半边:真实 React 19 + jsdom,跑 effect、点安装按钮、断言 DOM
node tools/test-client.mjs

# Host 半边端到端:真实注册表 + 真实 HTTP 服务
node tools/test-host.mjs

# GitHub 来源实况校验:真实 api.github.com,受预算约束(默认 25 次分类)
node tools/test-github.mjs

# 复核「加 GitHub 扫描到底值不值」这个判断本身
node tools/github-delta.mjs

# 双语字典键一致性(少一个键,界面就会把原始 code 显示给用户)
node tools/check-locale.mjs

# 离线构建目录快照用于分析
node tools/build-catalog.mjs .reference/catalog.json
node tools/analyze-compat.mjs

# 用真实 host 命令行的 argv 验证 profile 目录解析
node tools/verify-profile-resolution.mjs @argv.json

验证「插件不消耗额外 token」

会话日志 session.v4.jsonl.zstd(append-only 的 zstd 帧序列,一次性解压只能拿到第一条记录)里有两类可用记录:

  • request/header 存着逐字的 data.header.tools —— 真正发给模型的工具 schema 数组,可以直接 diff
  • assistant/message 存着每请求的 usage:inputTokens / cacheReadTokens / totalTokens / outputTokens
# 每个会话的工具面 + 首个请求的真实 token
node tools/token-report.mjs "%USERPROFILE%\.dsh\sessions"

# 两个会话直接 diff:工具增删 + 首请求 token 变化
node tools/token-report.mjs --diff  

# 每个工具 schema 各占多少 token
node tools/measure-tool-cost.mjs "%USERPROFILE%\.dsh\sessions"

# 找出是什么在改变工具面
node tools/probe-tool-surface.mjs "%USERPROFILE%\.dsh\sessions"

实测:工具面本身约 26,558 字符 ≈ 7,588 tokens/请求。本插件注册 0 个工具,因此加成是 0。反过来,官方 plugin_manager 工具本身约 699 tokens/请求,cordis_inspect_query 约 320,cordis_inspect_list 约 160。

tools/test-client.mjs 不是打桩渲染:它在 jsdom 里用真实 React 渲染真实注册的组件、驱动 effect(因此真的去打 HTTP 接口)、真的点安装按钮,并断言产生的 DOM。

反向得到的、写代码时依赖的契约

这些不是猜的,是读官方源码与 TypeScript 声明得到的,直接决定了实现:

  • 浏览器半边是 classic script(document.createElement('script'),无 type=module),因此不能用顶层 import/export/await,也不能用 JSX。

  • require() 只解析 9 个平台种子(react、react-dom、@deepseek-ai/dsh-client-ui-primitives 等)+ 已注册的行;其它裸包名必须打进产物。所以本插件不依赖任何第三方前端库。

  • window.__ModuleLoader__.load({id}) 的 id 必须等于包名;exports["./client"] 必须存在且为字符串,否则客户端加载器抛错。

  • sidebar.panellist 是 list slot(需要 id),main 是按 key 索引的 keyed slot,且 main 的 key 必须等于 panellist 的 id。

  • plugins.detail.badge / .section 的 owner props 只有 {subject}(没有 view);subject.pkg 只有 {name, version?, installed, enabled, rows},不含 title/description/icon。

  • ManagementError 是 {code, diagnostic?, incompatible?} —— 没有 message 字段;incompatible[].peers 是「包名 → range」的记录。

  • ChangeResult.application ∈ applied | restart-required | overridden | failed | cancelled,且 pendingBuilds 在 ChangeResult 顶层。安装失败可以是「已 resolve 的成功响应」(ok: true 但 application: "failed"),因此不能只看 ok。

  • Host 进程里取不到 profile 目录,而每一个「显然」的答案都是错的:

    • DSH_PROFILE_DIR / DSH_PROFILE 在 host 进程里不存在 —— DSH 只把它们注入工具子进程的环境(dshEnvironment.collect),所以在终端里能看到、在 host 里看不到。这是本项目踩过的真实坑:第一版把缓存写到了 .dsh\.plugin-market\。
    • hmr.baseDir 是真实公开字段,但只有在服务就绪后才可读;在 apply() 期间读到 undefined 是合法的。
    • 真正的权威信号是 process.argv:桌面端启动形式是 … dsh-desktop-host/lib/index.js /dsh ,CLI 形式是 dsh --profile ,而命令行从第一刻起就可用。

    因为每个来源在证明之前都只是猜测,所以每个候选都要结构化验证:真 profile 目录的 package.json 里必须有 dsh.profile。这条判据能排除启动器自己的路径(asar 子路径在 Electron 里看起来也像可读目录),因此猜错时只会退化到 DSH home,而不会写进无关目录树。/health 回报最终选中项与全部被拒候选,让错误答案可诊断。


安全模型

这个插件向浏览器暴露一个 HTTP 接口,并消费 npm 注册表返回的数据。两件事都不该被默认为可信,所以边界写在下面,并各有回归测试(test/security.test.mjs)。

前提:载体不提供鉴权

读官方源码可以确认:webServer.register 注册的路由,handler 是裸调用的,DSH 不做任何鉴权(webserver.index.js),而 bind host 的 schema 允许 0.0.0.0。也就是说,凡是这里提供的东西,能连到这个端口的人都能读。

由此推出三条硬约束:

  1. 这里不许输出任何凭据。 私有注册表的 URL 可能内嵌凭据(https://user:token@npm.internal/)。它不止出现在一个地方:/health 响应、目录 summary(既发给浏览器、又落盘到缓存文件)、日志行、pluginMarket.health() 服务方法,以及失败时的错误消息(那句消息会被记录、被重新抛出、还被拼进别的错误里)。因此脱敏发生在错误消息构造的那一刻,而不是在某个输出口 —— 否则总有一个出口会被漏掉。请求本身仍然用带凭据的真实 URL。
  2. 内部错误不回显给调用者。 500 响应体只有 {error:"internal"},完整错误只进日志。原因很直接:错误消息里可能带注册表凭据,也可能带文件系统错误里的绝对路径,而这个端点没有任何鉴权。
  3. 暴露本机状态的端点只对本机开放。 /official 报告的是本机装了哪些插件、哪些是启用的 —— 它不是用户的密码,但它是本机状态,因此仅限回环地址访问。公共目录仍然可读,所以从别的设备浏览插件依然可用,只是看不到本机安装列表(前端会给出明确原因,而不是一个裸状态码)。

任意网页都不能驱动本插件

回环判定本身是弱的,这一点必须说清楚:当 DSH 前面挂了本机反向代理时,所有请求的 remoteAddress 都是 127.0.0.1。

更普遍的问题是,用户浏览器发出的跨站请求也来自回环地址。所以 `

` 这种写法能天然通过回环检查 —— 一次全量扫描就这样被任意网页触发了。

因此每个请求都必须带一个自定义头 x-plugin-market:

  • 跨站的 、、`` 无法设置请求头,请求直接被拒(403)
  • 跨站的 fetch 想带头就会触发 CORS 预检,而本插件不返回任何 CORS 头,预检必然失败,真实请求根本不会发出
  • /refresh 另外还要求 POST,所以链接、预取、浏览器导航都不会构成变更

这个头是 CSRF 防护,不是鉴权:它证明调用方是跑在 DSH 同源页面上的脚本,而不是别处的页面。它挡不住能直接连到这个端口的本机程序,所以下面那些上界依然必要。

会做工作的端点

/refresh 会全量扫描注册表,因此限回环 + 要求 POST + 60 秒冷却。

冷却不是多余的:既然回环判定可被绕过,冷却就让无论谁在问,扫描次数都有上界。

/list 和 /detail 在冷启动时也会走到构建路径,所以扫描的代价有三个上界:

上界值防的是什么
单次候选数2000种子查询最多能点出 3000 个包名,恶意注册表可以把每一页填满
单次扫描时长5 分钟一个卡住 manifest 的注册表能把扫描拖到几十分钟
失败后退避60 秒注册表坏掉时,每个 list 请求都重新发起一次扫描

超时的扫描算失败,而不是返回「已经验证完的那部分」:把残缺目录当成完整目录缓存和排序,比继续用旧目录更糟。

不信任注册表返回的数据

搜索响应里的包名会被插进请求路径,版本会被拼进安装 spec。于是一份恶意或仅仅是坏掉的注册表响应,理论上能做路径穿越(URL 解析器会规范化 ..)。因此:

  • 包名在进入 URL 之前先过 npm 自己的字符合法性检查(只允许 [a-z0-9-._~] + 一个可选 scope,拒绝 ..、空白、控制字符、?、#、%,以及会产出 - 开头 spec 的前导 -)
  • 版本号同样校验字符集
  • 前端在把一行变成 ${name}@${version} 之前再校验一次 —— 目录数据来自注册表,属于不可信输入,即使官方管理器自己也会解析这个 spec

版本必须是确切版本,不能是 dist-tag 或范围。 这是一条独立的要求:注册表完全可以对 /pkg/latest 回一个 "version": "latest",那种 manifest 的 peer 依赖会被照常评估、徽章显示「兼容」,而安装 spec 会变成 pkg@latest —— pnpm 装的就不是刚才审阅的那个版本。所以 semver.valid() 是安装资格的一部分,拿不到确切版本就标记为不可安装(unpinned-version)并说明原因。同理,写不出确切运行时版本时判定为未知(runtime-unknown)而不是「兼容」:猜「兼容」是撒谎,判「不兼容」会冤枉整个目录。

资源上界

响应体积有上限(搜索 8 MB,单个 manifest 1 MB)。没有这个上限,一份超大的响应就足以让 host 进程 OOM。上限是流式执行的,所以谎报的 Content-Length 也拦得住,不是只看头部。/-/ping 探测更彻底:body 直接 cancel,根本不读。

光有单响应上限还不够 —— 缓存会永久保留每个包的投影,所以还需要聚合上界:

  • projectManifest 截断 description(500 字符)、keywords(20 个 × 40 字符)、homepage(300 字符)、maintainers(10 个)
  • manifest 缓存有条数上界 2000 与字节上界 8 MB,超出按插入顺序淘汰最旧的
  • **非插件不