Skip to content

Releases: EasonMWS/dsh-plugin-health

2.2.1 — 真实跑一次才发现的

Choose a tag to compare

@EasonMWS EasonMWS released this 04 Nov 05:35

一键更新真跑一次就露馅了

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 — 一键更新

Choose a tag to compare

@EasonMWS EasonMWS released this 04 Nov 05:26

检查完可以直接点更新

之前:面板告诉你「有新版本 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 — 让网站能被找到

Choose a tag to compare

@EasonMWS EasonMWS released this 04 Nov 05:08

网站之前是「存在但找不到」

首页只有一个标题

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 — 不再评价别的工具

Choose a tag to compare

@EasonMWS EasonMWS released this 04 Nov 04:37

去掉对别的工具的评价

生态里有好几个功能重叠的包(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 补到当前版本

Choose a tag to compare

@EasonMWS EasonMWS released this 04 Nov 04:20

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 路不通,改用请求头

Choose a tag to compare

@EasonMWS EasonMWS released this 03 Nov 16:48

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 — 重启后按钮失效

Choose a tag to compare

@EasonMWS EasonMWS released this 03 Nov 16:32

重启后按钮就废了

你把 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 — 把上一版没修住的四处修住

Choose a tag to compare

@EasonMWS EasonMWS released this 03 Nov 16:14

上一版(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 — 打开就查更新,顺手加了两个入口

Choose a tag to compare

@EasonMWS EasonMWS released this 03 Nov 16:01

打开 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 — 把承诺变成真的

Choose a tag to compare

@EasonMWS EasonMWS released this 03 Nov 15:03

两份独立审查发现 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