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 <y 复合区间。
一个容易反直觉的点:<0.2.0 这个上界会匹配 0.2.0-rc.2 —— 因为预发布版本排序在正式版之前,而 includePrerelease 让这个比较成立。
实测数据(运行时 0.2.0-rc.2):
| 指标 | 数量 |
|---|---|
| 检索到的候选包 | 1 571 |
| 验证为真实 DSH 插件 | 1 224 |
| 社区插件 | 1 140 |
| 官方包 | 84 |
| 可作为 bundle 安装 | 1 136 |
| 与当前运行时不兼容 | 218 |
仅客户端(官方管线会以 not-bundle 拒绝) |
88 |
约 19% 的社区插件在当前版本上会被 DSH 拒绝。官方面板只在你点下安装之后才告诉你,本插件在你点之前就标出来,并逐条列出是哪个 peer 的哪个 range 拦住了。
架构
dsh-compat-market
├── cordis.patch.yml # 一行 insert;host 与 client 由这一行共同装配
├── package.json # dsh.bundle.patch + dsh.client(platform:web)
└── lib
├── index.js # Host 半边:Cordis 插件(name / inject / apply)
├── client.js # Browser 半边:classic script + __ModuleLoader__.load
└── host
├── catalog.js # 发现与验证(多 seed 检索 + manifest 校验)
├── compat.js # 兼容性门禁(DSH 预检的移植)
├── market.js # 服务核心:分页 / 筛选 / 排序 / 安装委托
├── registry.js # 注册表可达性探测
├── route.js # 只读 HTTP 接口
├── cache.js # 快照 TTL + manifest 缓存
└── store.js # profile 内的原子 JSON 存储
目录数据源
纯自建聚合,不依赖任何第三方目录站(dsh-plugin.org 与 dshbase.com 都没有公开 JSON API,只能抓 HTML)。有两个来源,主次分明。
主来源:npm 注册表
- 对
dsh-plugin/deepseek-harness/dsh三个种子做全文检索 (npmmirror 的检索不支持 npm 的keywords:/scope:限定语法,两者都返回total=0) - 按包名模式粗筛后,逐个读取
/<pkg>/latest,以 manifest 是否声明dsh字段为权威判据dsh.bundle.patch→ 可安装的 bundle 层dsh.client→ 带浏览器半边- 官方核心包(
dsh-base、dsh-plugin-manager、dsh-tool-todo…)没有dsh字段 —— 它们是 bundle patch 声明的行,不是可安装单元,因此不会被当作安装候选
- 热门度用 npm 下载量(检索响应里自带
downloads),而不是 GitHub star
次来源:GitHub topic(滴灌)
注册表看不见从未发布过的插件,而这个缺口可以量化:topic:dsh-plugin 里最近更新的 100 个仓库,抽样 14 个有 10 个是带 dsh.bundle.patch 的真插件,注册表一个都不知道。复跑 node tools/github-delta.mjs 可以重新检验这个判断,而不是凭它当真。
只看 updated,不看 star。 实测按 star 取前 100 名,抽样 12 个没有一个是插件 —— 前排全是误挂该 topic 的无关知名项目(PicGo、NocoBase、Tencent/WeKnora),真插件几乎都停在 ★0–2。按 star 扫等于花配额进口噪音。
滴灌而非全扫,因为一个仓库不等于一个可安装单元:可安装单元是 package.json 里 dsh.bundle.patch 声明的 bundle,所以每个候选都要花一次 API 请求才能判断它到底是不是插件。未认证配额只有 60 次/小时,而搜索 API 单次查询最多返回 1000 条、该 topic 有 1.7 万+ 仓库 —— 全扫在数学上不可能。于是:
| 参数 | 值 | 理由 |
|---|---|---|
| 单轮分类预算 | 40 次请求(启用 token 后 250) | 配额与机器上其他工具共用,必须留余量 |
| 提前停止 | 剩余 ≤ 5 即停(启用 token 后 ≤ 200) | 宁可少扫,也不把共用配额抽干 |
| 每仓库复核周期 | 24h | 付过一次就不再重复付 |
| 发现窗口 | 搜索 API 前 10 页(≤1000 条) | API 硬上限 |
| 每轮取页 | 游标 1→10 循环 | 第 1 页最新,循环才能发现重新活跃的仓库 |
| 凭据 | 默认无(可选开启,见下) | 插件默认不存在任何秘密;代价就是"滴灌" |
代价说清楚:首轮只覆盖几十个仓库,之后靠累计,几天才接近完整。这是刻意的取舍 —— 默认不引入凭据比"立刻扫完"更重要。未配置 token 时该模块从不发 Authorization 头,这一点由单测与实况校验双重断言。
这个来源可以在界面里关掉,也可以不动代码用环境变量关掉。 侧栏「插件市场」→ 右上角「设置」→ 把「来源开关」切到「已关闭」即可:不会发出任何请求,/health 会报 github.disabled("关掉"与"失败"在响应里是两个不同的值,不会混淆)。环境变量 DSH_MARKET_GITHUB_TOPIC 优先级更高:设为 off 即完全停用,设为别的值则改扫另一个 topic,不设则用内置的 dsh-plugin(此时以界面开关为准)。关掉之后 npm 目录照常工作 —— 主来源从来不依赖它。所以"要不要保留 GitHub 扫描"是一个可以在运行时反悔的决定。
可选:启用 GitHub token(默认关闭)
滴灌之所以慢,唯一的原因是没有凭据。GitHub 未认证配额是 60 次/小时,带 token 是 5000 次/小时 —— 83 倍。所以这是一个用速度换"更少的秘密"的取舍,而取舍权在你。
方式一:在界面里填(不用重启)。侧栏「插件市场」→ 右上角「设置」→ 在「GitHub token(可选)」里粘贴 token → 「保存」。保存后输入框会被清空 —— 服务端没有回传明文的接口(/settings 只回答"有没有配",不回答"是什么"),所以界面永远不回显已保存的值。想撤销就点「清除」。
方式二:用环境变量(优先级更高;适合 CI,或你要求"绝不落盘"):
# 启用(显式指名给本插件的 token)
DSH_MARKET_GITHUB_TOKEN=<your-token>
# 关闭 / 恢复匿名(默认就是这个状态)
DSH_MARKET_GITHUB_TOKEN=off
⚠️ 开启意味着什么,请先读完再决定
- 这个 token 会被发送给 GitHub,每个请求都带
Authorization: Bearer <token>,GitHub 端能看到调用者是这个 token 对应的账号。- 消耗的是这个 token 的配额,不是 token 本身。 token 不会被"用掉",被消耗的是它名下每小时 5000 次的调用额度;额度用尽后该 token 的所有调用(包括你自己在用的其他工具、CI)都会一起被限流到下一个整点。这也是为什么启用后仍保留 200 次的余量:这份配额很可能不只你在用。
- 额度是按 token 计的,不随插件卸载而恢复;卸载插件不会撤销这个 token。
- 在界面里保存的 token 会以明文落盘。 位置是
<profile>/.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 侧构建并缓存在
<profile>/.plugin-market/catalog.json,经同源 HTTP 前缀路由/api/plugin-market暴露只读接口。 之所以用普通路由而不是 Typert Remote:安装已经走预生成的pluginManagerremote 命名空间,本插件唯一需要暴露给自己浏览器半边的只是只读目录 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 树验证 —— 这直接证明浏览器半边被加载并完成了注册,而不只是「文件存在」。
开发
# 单元测试:兼容性门禁 + 服务核心 + 存储
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 数组,可以直接 diffassistant/message存着每请求的usage:inputTokens/cacheReadTokens/totalTokens/outputTokens
# 每个会话的工具面 + 首个请求的真实 token
node tools/token-report.mjs "%USERPROFILE%\.dsh\sessions"
# 两个会话直接 diff:工具增删 + 首请求 token 变化
node tools/token-report.mjs --diff <日志A> <日志B>
# 每个工具 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 <asar>/dsh <profileDir>,CLI 形式是dsh --profile <name>,而命令行从第一刻起就可用。
因为每个来源在证明之前都只是猜测,所以每个候选都要结构化验证:真 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。也就是说,凡是这里提供的东西,能连到这个端口的人都能读。
由此推出三条硬约束:
- 这里不许输出任何凭据。 私有注册表的 URL 可能内嵌凭据(
https://user:token@npm.internal/)。它不止出现在一个地方:/health响应、目录summary(既发给浏览器、又落盘到缓存文件)、日志行、pluginMarket.health()服务方法,以及失败时的错误消息(那句消息会被记录、被重新抛出、还被拼进别的错误里)。因此脱敏发生在错误消息构造的那一刻,而不是在某个输出口 —— 否则总有一个出口会被漏掉。请求本身仍然用带凭据的真实 URL。 - 内部错误不回显给调用者。 500 响应体只有
{error:"internal"},完整错误只进日志。原因很直接:错误消息里可能带注册表凭据,也可能带文件系统错误里的绝对路径,而这个端点没有任何鉴权。 - 暴露本机状态的端点只对本机开放。
/official报告的是本机装了哪些插件、哪些是启用的 —— 它不是用户的密码,但它是本机状态,因此仅限回环地址访问。公共目录仍然可读,所以从别的设备浏览插件依然可用,只是看不到本机安装列表(前端会给出明确原因,而不是一个裸状态码)。
任意网页都不能驱动本插件
回环判定本身是弱的,这一点必须说清楚:当 DSH 前面挂了本机反向代理时,所有请求的 remoteAddress 都是 127.0.0.1。
更普遍的问题是,用户浏览器发出的跨站请求也来自回环地址。所以 <img src="http://127.0.0.1:19387/api/plugin-market/refresh"> 这种写法能天然通过回环检查 —— 一次全量扫描就这样被任意网页触发了。
因此每个请求都必须带一个自定义头 x-plugin-market:
- 跨站的
<img>、<script>、<form>无法设置请求头,请求直接被拒(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,超出按插入顺序淘汰最旧的
- 非插件不入缓存:一次扫描最多能点出几千个包名,为每个非插件长期保留一份投影是纯内存开销
- 载入时也会裁剪,因此旧版本写下的超大缓存文件会被自动收敛
注入面
- 客户端不用
innerHTML/dangerouslySetInnerHTML/eval/new Function,没有字符串拼 HTML - 没有任何
href—— 注册表提供的repository/homepage是纯文本渲染。这一点是刻意的:如果渲染成链接,一份恶意 manifest 只要填javascript:就能在 DSH 自己的页面里执行脚本,而那个页面能调用remote.pluginManager.installBundle - 原型污染不可达:唯一一处「不可信键赋值」由 peer 名校验把关;缓存键必然含
@;投影只复制固定字段
不增加模型上下文
本插件注册 0 个工具,也不注册 systemPrompt section/context/variable。实测每个请求的工具面约 26,558 字符 ≈ 7,588 tokens,本插件的贡献是 0。目录数据走同源 HTTP 直接进浏览器,不进模型上下文。
(对照:官方 plugin_manager 工具本身约 699 tokens/请求,cordis_inspect_query 约 320,cordis_inspect_list 约 160。)
它不做的事
- 不自己安装任何东西 —— 安装、启用、卸载全部委托给官方
pluginManager,因此注册表回退、package.json/pnpm-lock.yaml回滚、构建脚本授权、兼容性预检都用官方的实现 - 默认不碰任何凭据。 不自动读取环境里的
GITHUB_TOKEN/GH_TOKEN/GITHUB_PAT,不 shell 出gh auth token,未配置时一个Authorization头都不发。唯一的例外是你主动在面板里填的那个 token —— 它只存在本机设置文件里、只发给api.github.com、且永远不回传给浏览器(细节见上文「可选:启用 GitHub token」) - 不执行注册表数据里的任何内容
你可以自己验的
node --test test/*.mjs # 133 项,7 个文件,全绿
node --test test/security.test.mjs # 24 项:脱敏、包名/版本校验、体积与缓存上界、CSRF 头、端点鉴权、冷却
node --test test/encoding.test.mjs # 5 项:编码损坏守卫(U+FFFD / NUL / BOM)
node --test test/compat.test.mjs # 18 项:兼容性门禁与安装资格
node --test test/github.test.mjs # 34 项:滴灌预算、配额退避、TTL、路径穿越、体积上限、开关、凭据边界
node --test test/settings.test.mjs # 20 项:环境变量优先级、落盘、/settings 只允许本机、token 永不出现在响应里
node --test test/store.test.mjs # 18 项:profile 定位、缓存与设置分离、0600 权限、损坏文件读作缺失
node tools/check-locale.mjs # 双语字典键一致(93 键 ×2)、无死键、不渲染原始 code
node tools/test-client.mjs # 浏览器半:面板会真的切换开关,且从不回显 token
node tools/test-plugin.mjs # 装配:bundle patch、client 注入、exports 可达
node tools/test-github.mjs # 实况:真实 GitHub topic,断言从不发 Authorization 头
已知限制
- GitHub 来源是滴灌的,不是即时全量。 匿名时单轮最多分类 40 个仓库、剩余配额 ≤5 就停、每仓库 24h 内不重复读;启用 token 后是 250 / 余量 200。搜索 API 单次查询最多 1000 条而该 topic 有 1.7 万+ 仓库,因此永远覆盖不全。这是配额硬上限,不是可以调参绕开的:只有开启 token(83 倍配额)能实质改变覆盖率,而那要以交出凭据为代价。
- 滴灌只在目录重建时推进(快照过期 6h,或手动点「重建目录」)。所以匿名模式下约 6 天才走完发现窗口;启用 token 后约 4 次重建即可。想更快推进就多点几次重建 —— 配额守卫会在余量耗尽前替你停手,不会失控。
- GitHub 条目不做提交固定。 安装 spec 就是仓库地址,pnpm 取默认分支当下的状态;界面上的结论也只是那次扫描时的默认分支状态,所以标为「临时」。安全上没有缺口(官方预检会在实际取到的产物上再跑一次),但可复现性弱于 npm 条目:同一行两次安装可能装到不同提交。要固定,需要在安装前解析 HEAD sha 并拼成
#<sha>;DSH 的 spec 解析器接受这个形式(HOSTED_REPOSITORY_URL尾部有(?:#.*)?),这是明确的下一步。 - 不做版本选择/升级:与官方面板一致,升级靠卸载后重装(官方当前也不支持就地升级)。
- 不按 star 排序:star 只是
github条目的展示字段。实测该 topic 按 star 排序前排全是误挂 tag 的无关项目,拿它排序等于花配额进口噪音。 - 官方分组取自随装的组合包,不使用注册表上的官方包 —— 注册表上的官方包常有比随装版本更旧的发布(实测 84 个官方包中 57 个与当前运行时不一致)。
- 缓存按 profile 隔离,因此多 profile 会各存一份约 1.5 MB 的 manifest 缓存。
许可
MIT
No comments yet. Be the first to write one.