Repository navigation
Releases: EasonMWS/dsh-plugin-health
Release list
2.2.1 — 真实跑一次才发现的
一键更新真跑一次就露馅了
2.2.0 的安装模块,单元测试全绿——因为测试里是注入的假运行器。
我拿真实 pnpm profile 跑了一遍,两个 bug 立刻暴露:
① 在真实 pnpm 上直接失败
error: unexpected argument '--no-frozen-lockfile' found
tip: a similar argument exists: '--no-lockfile-only'
pnpm 12 的 add 已经不认这个参数了,命令在碰到任何文件之前就以用法错误退出。
真正需要的是 --yes——把提问变成回答的那个。
② Windows 上退到了 shell
原来找的是 node_modules/pnpm/bin/pnpm.cjs,但 pnpm 的主文件叫 pnpm.mjs,所以找不到,
退到了 .cmd shim + shell: true——shell 模式下参数是拼接的,不是转义的。
pnpm 自带原生 pnpm.exe。现在用它:不用 shim、不用 shell、没有转义问题。
修完实测
profile : 真实 pnpm profile
当前版本 : 2.1.0
耗时 : 4s
phase : done
磁盘实际 : 2.2.0
日志(pnpm 自己的输出):
dependencies:
- dsh-plugin-health 2.1.0
+ dsh-plugin-health 2.2.0
Done in 4.3s using pnpm v12.5.1
顺带:一条测试写得太死
CI 把 pnpm 装在 Node 目录之外,所以 .cmd 回退路径在 CI 上是真实存在的。
我的断言要求必须有原生 exe,本地过、windows-latest 挂。
改成按形状断言:shell 只允许用于 shim,包名在每条路径上都保持单个参数。
另外加一条测试强制走回退路径(把 execPath 指向空目录),这样在有 pnpm 的机器上也能覆盖。
309 tests(新增 3),1 平台跳过,Windows/Node 22 CI 全绿。
2.2.0 — 一键更新
检查完可以直接点更新
之前:面板告诉你「有新版本 2.2.0」,然后打印一行命令,让你自己开终端。
这个插件存在的意义就是不让你开终端。
现在检查完会出现「立即更新」按钮。
这是本包最危险的动作
其他所有写入都是它自己拥有的文件里一条可撤销的记录。**这个不一样——它要在 DSH 正在启动的 profile 里替换插件自己的代码。**装坏了,面板打不开,最坏情况 DSH 起不来。
所以做得非常保守:
用 profile 自己的包管理器
你的 profile 是 pnpm workspace(有 pnpm-lock.yaml、.pnpm)。
往里塞 npm 会产出 pnpm 读不懂的树,然后下一次 dsh plugin add 会以一个跟插件毫无关系的理由失败。
所以是检测,不是假设。
非交互
pnpm 会问「这个版本太新,确定要装吗」,还会问供应链策略。在没人看着的后台进程里,一个提问就是永久卡死。
所以带上把这些提问变成决定的参数,同时有超时兜底。
看在磁盘上的结果,不看退出码
包管理器可能退出码 0 但文件没变(no-op add、嵌套 workspace、lockfile 打架)。
信退出码会显示"更新成功",而用户还在跑旧代码。
版本号先校验
它会被拼进命令行,所以先验证格式。只装检查到的那个确切版本,绝不装范围。
一次只跑一个
连点两次,第二次加入第一次,不会启动两个包管理器抢同一个目录。
说需要重启,但不替你重启
文件变了,运行中的宿主还是加载时的代码。
替你重启会不问一声就结束你的会话。
侧栏小提示
启动时那次检查的结果已经在 status 快照里了,所以侧栏提一句不需要额外请求。
- 没有更新就什么都不显示
- 有坏插件时让位给坏插件——插件加载不了才是用户打开这个面板的原因
失败要能看懂
不是给一个退出码,是这样:
更新失败:the package manager exited with code 1
┌──────────────────────────────────────────┐
│ ERR_PNPM_FETCH_404 GET https://registry │
└──────────────────────────────────────────┘
[重试]
测试
新增 14 个,重点在失败路径:
检测到 pnpm 而不是假设 ← 检验
带上非交互参数 ← 否则会卡死
拒绝 2.0.0; rm -rf / ← 命令行注入
退出码 0 但磁盘没变 → 报失败 ← 不信退出码
超时不当作成功
连点两次只跑一次
无密钥不能安装 ← 401
GET 只报告不安装
306 tests(新增 14),1 平台跳过。
2.1.6 — 让网站能被找到
网站之前是「存在但找不到」
首页只有一个标题
title : DSH Plugin Health Index ← 作品的名字,没人会这么搜
description : (无)
og:title : (无)
canonical : (无)
「DSH Plugin Health Index」是这份数据的名字,不是任何人会打进搜索框的词。
现在描述以症状开头:
Check which DeepSeek Harness plugins still load, and repair the ones an upgrade left behind.
Compatibility table for every catalogued plugin, plus a format-1 market index.
分享出去是个裸链接
两页都没有 og: 标签,贴到 Discord / Reddit / 微信都是一个光秃秃的 URL。
现在两页都有 og:title / og:description / og:url / twitter:card / canonical。
没有 sitemap,没有 robots.txt
之前爬虫只能靠跟踪链接发现兼容表和索引。现在部署时生成:
https://easonmws.github.io/dsh-plugin-health/ priority 1.0
https://easonmws.github.io/dsh-plugin-health/compat/ priority 0.9
https://easonmws.github.io/dsh-plugin-health/index.json
https://easonmws.github.io/dsh-plugin-health/compat/matrix.json
这四个是每天真的会变的东西,所以 changefreq: daily,其余不写。
README 第一屏现在有网站链接
之前那个 4,389 插件的表在 200 行之后。不想装包的人看不到。
现在两版都在第一屏:
Not ready to install? Check any plugin first, in the browser:
https://easonmws.github.io/dsh-plugin-health/compat/
一个容易写错的地方
sitemap 的命名空间是 sitemaps.org,不是 sitemap.org。
我第一版就写错了,而且这种错误不会报任何错——文件照样生成,爬虫直接忽略整个文件。已加测试锁住。
加了 4 条测试
首页必须有 description / canonical / og:title / og:description / og:url / twitter:card
兼容页必须有同样的一组
部署必须生成 sitemap.xml 和 robots.txt,且命名空间是 sitemaps.org
两版 README 的第一屏(前 40 行)必须有兼容表链接
292 tests(新增 4),1 平台跳过。
2.1.5 — 不再评价别的工具
去掉对别的工具的评价
生态里有好几个功能重叠的包(dsh-plugin-health-check、其他的体检 CLI、市场扫描器)。
说「唯一一个能修复的检查器」或者「静态检查工具会把这些报成坏插件」,
是替别人下结论——读者没法验证,而且这个论点并不需要它。
改成了什么
原来:
A static checker reads declarations. That is layer one of four — and on its own it is wrong often
enough to be dangerous.The first two are false alarms a static checker would report as broken.
现在:
Reading declarations is the first layer. Four things happen between a plugin being rejected and
your DSH working again.By declaration, all three are equally rejected. Two of them boot without complaint; one does not.
Reading declarations cannot tell them apart, which is why the rehearsal runs before anything is
written.
数据本身就是论点,不需要贬低别人来衬托。
标题也跟着改
| 之前 | 现在 |
|---|---|
| What makes this different: four layers, not one | What happens between "rejected" and "fixed": four layers |
| 特色:四个层次,不是一层 | 从「被拒绝」到「修好」之间做了什么:四个层次 |
npm 描述也改了
之前:The DSH plugin checker that can actually repair what it finds.
现在:Finds the plugins a DSH upgrade left behind and repairs them: ...
去掉 "can actually"——那是在跟别的东西比。
诚实的对比保留了
「读声明分不出它们的区别」这句留着。它说的是本包自己的方法,不是别人的能力。
加了测试
test/docs-tone.test.js 会因为这些挂掉:
the only <thing> unlike other tools can actually
only <tool> no other <thing> 唯一能…
独有 / 独家 / 首个能 静态检查工具只会…
但故意不匹配这些无辜用法:
only then decides we only prove it boots
read-only Git-only plugins browser-only plugins
一开始我用的是「ban the word only」的写法,结果把上面全部误报了。
规则改成看位置:the only <名词> 才是比较性声明,only then 不是。
288 tests(新增 3),1 平台跳过。
2.1.4 — README 补到当前版本
README 停在 1.1.0
外部审查抓到的,确实存在。
安全章节从 1.1.0 就没再动过。11 月打开这个页面的人,会以为最新的问题是 1.0.2 那个密钥泄露——
而实际上 2.0.0 已发过一个 high 级公告,2.1.1 修掉了一个能让局域网客户端禁用你插件的绕过。
现在改成版本表,1.1.0 / 2.0.0 / 2.1.1 / 2.1.3 逐版列清楚,两个公告都引上,
并把「宿主绑局域网地址时该怎么办」写明。
2.1.0 的功能根本没写进 README
- 打开 DSH 自动查更新
- 检查更新按钮
- 浏览全部插件健康状况按钮
DSH_PLUGIN_HEALTH_NO_UPDATE=1开关
这些全都在 CHANGELOG 里,README 一个字没提。中英文两版现在都有按钮表格,
并解释了为什么自动检查是安静的。
「1.0 承诺什么」描述的是一条已经不发的线
改名 2.x,并把 2.0.0 那三处故意破坏复述一遍,免得冻结清单被误读成覆盖了它们。
状态路由那一行补上了 2.1.3 新增的 key 字段——安全相关的字段增加,不该让人靠抓包才发现。
加了 5 条测试,防止再次漂移
两种语言必须记录同样的功能 ← 这次就是中文版漏了
两个公告都必须在两个文件里被引用 ← 这次就是只引了第一个
升级建议不许指向已被取代的版本 ← 这次就是写着「升级到 1.1.0」
承诺章节的标题必须匹配当前主版本号 ← 这次就是 1.x 但实际已 2.x
所有内部锚点必须指向存在的标题
285 tests,1 平台跳过。
2.1.3 — cookie 路不通,改用请求头
2.1.2 猜错了原因
上一版我判断是"重启换了端口,浏览器带着旧 cookie"。这个判断是错的。
你重启后装了 2.1.2 还是有 401,说明刷新 cookie 没用——cookie 这条路在这个宿主里根本没通。
真正的原因
Set-Cookie 确实透传出来了(我验过响应头),但浏览器没有在写请求上把它带回来。
页面无从得知,我这边也看不到,所以每一版都在猜。
修法:不再依赖 cookie
/status 现在把密钥同时放进响应体。客户端从 body 里拿出来存在内存里,
每个写请求带 X-Plugin-Health-Key 头——这个头路由本来就认。
为什么这是可行的:面板能显示数据,就证明它能读到 /status 的 body。所以只要 key 在 body 里,
客户端就一定拿得到。而 cookie 是否被带回来,是不可观测的。
cookie 保留着,能用的宿主继续用;请求头是不依赖它的那条路。
安全性没有下降
我原来的注释说 cookie 比 body 安全,那是不准确的:
| 同机进程 | 别的端口页面 | |
|---|---|---|
Set-Cookie |
读得到 | 读不到(无 CORS) |
| body 里的 key | 读得到 | 读不到(无 CORS) |
同机进程本来就能读 Set-Cookie,SECURITY.md 里写得很清楚。真正挡住跨源页面的是
peer 地址校验 + Origin 同源校验,不是密钥存在哪。
唯一真实的差异:DSH 同源页面的脚本现在能读到这个值,所以别的插件里的 XSS 可以拿到它——
但那种插件本来就有你的完整权限。
顺带修了那句话
"查不到最新版本:missing or invalid control key" 把人往网络问题上带。认证失败现在明说:
「插件界面没有拿到本次启动的密钥,刷新页面即可」。
补了本该有的测试
http-smoke.js 加了这条:完全不带 cookie,只用从 status body 读出的 key 写一次。
这正是能提前发现这个 bug 的用例,之前没有。现在 19 项。
280 tests,279 通过,1 平台跳过。
2.1.2 — 重启后按钮失效
重启后按钮就废了
你把 1.1.0 升到 2.1.1 之后重启了 DSH,然后「检查更新」报 missing or invalid control key。
不是安全机制挡住了你,是我的 bug。
控制密钥每次启动重新生成,通过 cookie 交给页面。但 cookie 只按域名区分,不按端口区分。
DSH 重启后换了端口(52363 → 52426),浏览器还带着上一次启动的旧密钥。
于是每个写请求都是 401,而「检查更新」把所有失败都显示成"查不到最新版本",所以看起来像网络问题。
为什么刷新页面就好了
刷新会重新走一次 status 请求,那个响应会重新下发 cookie。
但"重启之后要手动刷新一下才能用"是个烂答案——用户不知道为什么要刷新,只会觉得插件坏了。
修法
三个写路径统一走一个 postWrite:
发请求 → 401?→ 读一次 status(它就是下发 cookie 的那个端点)→ 重试一次
→ 其他错误(400/415/403)→ 原样抛出
只在 401 重试:400 是服务器的明确答复,不是凭证过期,重试没有意义。
为什么之前测不出来
单元测试里的 fetch 是假的,永远拿正确的 key。http-smoke.js 每次请求都现取 cookie。
只有真实重启过的浏览器才会带着旧 key——这是必须靠真实安装才能撞见的 bug。
已加两条回归测试:一条模拟过期 key 验证会重试并成功,一条验证 400 不会重试。
279 tests(新增 2),278 通过,1 平台跳过。
2.1.1 — 把上一版没修住的四处修住
上一版(2.0.0)的修复,有四处没修住
这不是新问题,是旧修复没生效。而且 http-smoke.js 里有 3 项打在假对象上,等于没测。
① 回环校验只看 Host 头(中危;宿主绑局域网时高危)
Host 是客户端自己写的。DSH 的 webServer 支持绑 0.0.0.0 或具体局域网地址——那是官方 README
里的 "deliberate network exposure"。这种情况下,局域网内任意一台机器都能发
Host: 127.0.0.1:<port>,通过全部检查,从 /status 拿到控制 cookie,然后禁用任意插件、跑救援。
现在四个 handler 都额外校验 peer 地址必须是回环(127.0.0.0/8、::1、双栈下的 ::ffff:127.x)。
这是连接里客户端唯一不能选的部分。Host 检查保留——它挡的是 DNS rebinding。
② 请求体仍能决定装什么
requireInstalledPlugin 确认了 foo@1.0.0 已安装,然后把字符串 "foo@1.0.0" 往下传。
下游 runRuntimeSmoke 遇到磁盘上存在的 spec 会当成路径。
于是在 DSH 进程 cwd 里放一个名为 foo@1.0.0/ 的目录,里面 manifest 写什么,就是什么被安装、
被启动、被写进 compatibility.json。我复现时写进去的是 not-foo@6.6.6。
身份现在与 spec 分开传递(pluginName/pluginVersion + registryOnly 禁止路径解读),
下游不再从调用方给的字符串反推包名。
③ 宿主发现从来没被告知要找哪个版本
2.0.0 让 discoverDshDirectory 跳过版本不符的候选,但路由和 CLI 调用时都没传版本——
这个校验在最关键的路径上是死代码。结果:命中旧版缓存宿主 → 界面说"能修好"(canApply: true)
→ apply 永远 inconclusive(被 rescuePlugin 自己的版本断言拦住)→ 从 1.x 升上来的用户出不来。
④ 激活断言被子串匹配骗过
按 id 子串匹配,测 dsh-x 时一个激活的 dsh-x-ui 就算证据,插件被禁用或不存在也能通过。
改成包名与 id 都精确匹配,且同名条目全部必须激活。
其他
- chunked 请求体可以绕过 Content-Type 要求:判断"有没有 body"读的是
content-length,
chunked 没有长度 → 被当成空 body 放行 GET /repair不在 status 守卫内:写守卫对读方法直接放行disable/enable不校验包名:任意字符串会写进 profile 的 patch 层- 删掉死代码
RESCUE_KEY(从未被读,注释还说浏览器会收到它)
测试也修了
scripts/http-smoke.js 现在挂载真实的 registerRepairRoute / registerRescueRoute,
而不是直接调 handler。之前它的 rescue 用例绕过了守卫和密钥校验,3 项"通过"什么都没证明。
现在 16 项,包含 rescue 的负向用例和 chunked 用例。
文档
SECURITY.md 原本写"远程和跨源攻击者从来无法访问这些路由"。跨源那句是对的,
绑局域网地址时不对。已改成明确写出 peer 要求与局域网场景。
277 tests(新增 16),276 通过,1 平台跳过。
2.1.0 — 打开就查更新,顺手加了两个入口
打开 DSH 就自动查一次自己有没有新版本
一个过期的体检工具比没有更糟:它一边告诉你插件要处理,一边漏掉了给自己修的更新。
启动时发一次查询,不等待,DSH 启动一毫秒都不多花。结果跟着面板本来就轮询的 status 快照一起回来,
所以你打开设置页之前答案就已经有了。
它很安静:每次启动一次请求,之后最多每 6 小时一次,只有真的有新版本才提示,并附上完整命令:
dsh plugin --profile <profile> add dsh-plugin-health@2.1.0不会每次都弹一句"已是最新"——那会把人训练成无视提示条,而真有更新时提示条就在那里。
查失败就记成失败,不会写成"已是最新":这两句话不一样,只有一句是真的。
不需要它联网可以关掉:DSH_PLUGIN_HEALTH_NO_UPDATE=1。
界面上多了两个按钮
设置 → 插件健康,顶部现在有三个:
| 按钮 | 作用 |
|---|---|
| 重新扫描 | 立刻重跑体检 |
| 检查更新 | 手动再查一次(自动检查是安静的,所以需要这个出口) |
| 浏览全部插件健康状况 | 打开公开兼容性表,4,389 个插件 × 2 个宿主版本 |
第三个是真 <a target="_blank">,不是 click handler——桌面端会交给系统浏览器打开,
而不是试图在设置面板里渲染一个网页。
安全
新增的 POST /plugins/dsh-plugin-health/update 和其他写路由共用同一道围栏,没有单独开一个:
loopback 主机 → JSON 内容类型 → Origin 匹配 Host → Sec-Fetch-Site 非跨站 → 常量时间密钥校验
它是这个包里唯一会主动联网的控件,所以本机其它端口的页面不能触发它。
围栏抽成了一个共享的 authorize(),两个路由不会各自漂移。
顺带修了控制密钥比较:原来比 UTF-16 长度再比字节,48 个非 ASCII 字符是 96 字节,
timingSafeEqual 会在未捕获的 handler 里抛 ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH。
现在比 SHA-256 摘要。
验证
- 261 个测试(新增 23 个),260 通过,1 个平台跳过
- 11 项真实 HTTP socket 验证(
scripts/http-smoke.js) - 8 项针对真实路由注册的验证(
test/update-route.test.js),包括"跨源调用不会触网"
2.0.0 — 把承诺变成真的
两份独立审查发现 1.5.0 的多处保证其实不成立
这个工具对外承诺的东西,有几句是假的。这个版本把它们变成真的。
因为是破坏性变更,所以是主版本号:写路由现在要求 application/json,救援动作拒绝未安装的插件,
损坏的 compatibility.json 会被拒绝重写而不是覆盖。
安全(High)
① 本机任意端口的网页都能调用全部写路由
把密钥从响应体挪进 HttpOnly cookie 挡住了页面脚本,但也去掉了原本强制 CORS 预检的自定义请求头。
Cookie 不区分端口——127.0.0.1:3000 的页面和 DSH 是同站,SameSite=Strict 照样会把 cookie 带上,
而 text/plain 的 POST 属于简单请求。于是本地开发服务器、notebook、预览页都能调 apply、disable、
undo。
现在要求:JSON 内容类型(强制预检)、Origin 与 Host 一致、Sec-Fetch-Site 非跨站。
② 请求体可以指定安装并运行任意包
rehearse 和 apply 把 body.plugin 原样拼进 dsh plugin add。x@npm:evil、/tmp/pwn 或穿越路径
都会以用户环境安装并启动一个用户从没装过的东西。现在要求插件已在 profile 里、且版本完全一致。
③ 三条路径能在没对目标宿主做过预演的情况下授予豁免
- 下载的宿主缓存在一个不带版本号的目录里,宿主升级后预演跑的是上一版,豁免却写给新版;
runs判定只读details.ok,忽略超时、遗留子进程和非零退出;- 激活检查只断言"看得见的条目都激活了",从未断言被测插件在其中。
修复(数据与输出)
compatibility.json解析失败会被覆盖 → 现在写入前严格读取、保留未知键、原子写入 + 备份cordis.patch.yml重写丢光所有注释 → 现在改 AST,注释保留- 工具自己的备份被当成"残留文件"提示清理(会毁掉恢复副本)
- 跨两个宿主版本的 undo 会残留一条豁免
- 已关闭的插件仍被报告为不兼容
- 生成的 issue 建议范围不包含当前宿主(
ceilingFor把主版本写死成 0) - AI 提示词在没跑过测试时说"启动测试表明代码没问题"
- Windows 上下载演练宿主从来没成功过(Node 拒绝无 shell 启动
.cmd)
231 tests,1 个平台跳过。公告:GHSA-52w2-cq6j-xhg7