ARTICLE · 1065656
修复dsh插件报红包元信息错误:Plugin metadata for @deepseek-ai/dsh-x: TypeError: Cannot assign to read only property
DSH 插件全部报红:一次被误导性报错带偏的排查
修复前报错截图:


故障复盘 · Postmortem
8 个内置插件同时报「包元信息错误」,报错文字一模一样。乍看是插件各自的问题,实际上这两层根因里,第一层报错本身就是假的——真实错误早在被抛出之前就被另一个异常顶替了。
环境版本deepseek-harness0.1.7Node.js22.22.2系统Windows 11日期2026-09-23
TL;DR
现象:8 个@deepseek-ai/dsh-*插件在插件管理里全部标红,报错文本完全相同。
假报错:Node 22.19+ 起Error.prototype.stack是只读 getter,DSH 的throwWithImporter()对它赋值时抛 TypeError,把真正的ERR_PACKAGE_PATH_NOT_EXPORTED顶掉了。
真根因:这 8 个包的package.json里exports块尾部残留一组悬空键,整份文件是非法 JSON,导致pkg/locale/en.json无法解析。
关键发现:locale/en.json、locale/zh.json一直就存在且内容完整。缺的不是文件,是通道。
修复:重建单一exports块(落盘前校验 JSON)+ 给error.stack赋值加safeSetStack保护。验证 8 包 × 3 资源 =24/24 全部解析成功。
01 现象
打开 DSH 的插件管理页,内置插件列表里 8 个条目全部标红,每个都显示这样一段:
包元信息错误:Plugin metadata for @deepseek-ai/dsh-persona:
TypeError: Cannot assign to read only property 'stack' of object
'Error: Package subpath './locale/en.json' is not defined by "exports"
in .../node_modules/@deepseek-ai/dsh-persona/package.json'
受影响的插件:
dsh-persona、dsh-agent-instructions、dsh-tool-bash、dsh-tool-pwsh
dsh-tool-fs、dsh-tool-fs-search、dsh-tool-jobs、dsh-skill-filesystem
第一反应很自然:exports没导出./locale/en.json,那就去补这个导出声明。这个方向不算错,但如果顺着它走,会走进一个很长的死胡同——因为这条报错消息本身是假的。
02 第一道陷阱:报错本身是假的
Node 把 stack 变成了只读属性
从 Node 22.19 起(v24 全线同样),Error.prototype.stack不再是一个普通的可写属性,而是变成了只有 getter、没有 setter的访问器属性。在本机 Node 22.22.2 上实测:
$ node-e"const d = Object.getOwnPropertyDescriptor(new Error('x'), 'stack'); \
console.log('writable =', d.writable, '| hasGetter =', !!d.get)"
writable=undefined | hasGetter=true
writable是undefined,只有 getter。这意味着err.stack = '...'这行代码在正常情况下「静默失败」,而一旦Error实例被封冻(Object.freeze或被只读代理包裹),它就会直接抛:
TypeError: Cannot assign to read only property 'stack' of object ...
DSH 正好在错误路径上做了这行赋值
app-boot包里有这样一个函数,职责是把「模块路径解析失败」的错误包装得更易读——在消息尾部补上imported from <调用方>:
packages/boot/app-boot/src/profile-resolution/resolver.ts(改动前)
functionthrowWithImporter(error,routedParent,parent): never {
constoriginalMessage=error.message
constmessage=`${originalMessage}imported from ${routedParent}`
conststack=error.stack
error.message=message
if(stack!==undefined)error.stack=stack.replace(originalMessage,message)// ← 问题在这行
throwerror
}
它先改写message,再同步把stack里的旧消息替换成新的——很贴心的设计。问题就出在被标注的最后那行赋值。
错误链路
1 · DSH 读取插件元数据resolve('@deepseek-ai/dsh-persona/locale/en.json')2 · Node 严格 exports 校验目标路径不在白名单内 → 拒绝解析ERR_PACKAGE_PATH_NOT_EXPORTED这才是真正的故障信号throwWithImporter()error.stack = stack.replace(...)TypeError: Cannot assign to read only property 'stack'次生异常 → 顶替掉真实错误3 · 插件管理界面显示同一条假报错真实原因在到达 UI 之前就丢失了
一条实用的排查直觉
当多个互不相关的对象报出完全字面相同的错误时,先怀疑报错机制本身,而不是去逐个排查这些对象。真实世界里的故障很少如此整齐划一。
03 剥掉假报错后,真正的第一层根因
绕开那条 TypeError,直接去看插件包里的package.json——问题一目了然。下面是修复前的结构(已简化为示意;代码块中的//注释为说明用途,原文件里并不存在):
packages/preset/persona/package.json(修复前)
{
"name":"@deepseek-ai/dsh-persona",
"exports": {
".": { "types":"./lib/types/index.d.ts", "default":"./lib/index.js"},
"./package.json":"./package.json"
},
"./src/*":"./src/*",// ← 悬空:块已闭合,又冒出键值
"./package.json":"./package.json"// ← 悬空:且与前文重复
},// ← 悬空:多出来的闭合
"files": [ ... ]
}
exports块在},处已经闭合,后面却又跟了一组没有父级的键值对和一个多余的闭合括号。于是这份package.json是非法 JSON。
为什么非法 JSON 会导致这个报错
Node 的exports字段是一套白名单机制:声明了exports之后,包内只有被显式列出的路径才允许被外部import或解析,其余一律拒绝并抛出ERR_PACKAGE_PATH_NOT_EXPORTED。
但这里更早的一步就挂了:整份 package.json 解析不出来,那么exports白名单根本无从建立,@deepseek-ai/dsh-persona/locale/en.json自然解析失败。DSH 拿到这个失败,进入throwWithImporter()想美化一下——然后就撞上了第 2 节描述的stack只读问题。
本次最有价值的发现
这 8 个包里的 locale/en.json 与 locale/zh.json一直就存在,内容也完整、UTF-8 编码正确。缺的从来不是文件,而是「能访问到文件」的那条通道。
所以「去补 locale 文件」这个方向只对了一半——如果按它做,会新增一堆重复文件,而报错依旧。
为什么必须是全 8 个包
因为这些包的package.json来自同源模板,损坏形态完全一致:同样多一组悬空键、同样的位置、同样的重复。
换句话说,这不是 8 个独立 bug,而是1 个模板缺陷的 8 个副本。认清这一点后,修复方案就只剩一件事:改一次模板产物,批量套用。
另外附带一个省事的细节:apps/cli/node_modules/@deepseek-ai/下的这些包是指向 workspace 源包的符号链接,改源包即刻生效,不需要重装依赖。这也解释了为什么「重装一遍」解决不了问题——重装复制的是同一份坏模板。
04 修复:两件事,治本 + 防误导
A. 重建 exports 块(治本)
第一版我试图用正则去「手术」JSON——定位"exports"到下一个同级键之间的文本,替换成新块。结果越修越坏:因为用了惰性通配.*?,它只匹配到了块内第一个},,于是原始块的尾部和后续内容被割裂成了孤儿。这是本次最大的教训,后面第 6 节会详细复盘。
最终采用的方案分三步,核心是把结构判断交给 JSON 解析器,而不是正则:
先用解析器读出原exports里的全部键值对(在非法 JSON 上做容错提取,丢弃无法归属的悬空项);
补上"./locale/*.json": "./locale/*.json",重建为单一、闭合正确的块;
同时确保files数组覆盖locale(打包时不会漏掉),写盘前先做一次 JSON 合法性校验,不通过就不落盘。
修复后的exports块:
packages/preset/persona/package.json(修复后)
{
"name":"@deepseek-ai/dsh-persona",
"exports": {
".": { "types":"./lib/types/index.d.ts", "default":"./lib/index.js"},
"./package.json":"./package.json",
"./locale/*.json":"./locale/*.json"
},
"files": [
"lib/**/*",
"locale/*.json"
]
}
B. 给 error.stack 赋值加保护(防误导)
只修 exports 能消掉报红,但假报错机制还在——下次任何插件出问题,仍会被同一条假消息盖掉真实原因。所以顺手加一个防御性的赋值助手:
resolver.ts· 新增
/**
* 改写 error 的 stack,但不假设该属性可写。
* Node 22.19+ / v24 把 Error.prototype.stack 变成了只有 getter 的访问器属性,
* 直接赋值会抛 TypeError,并顶替掉真正的解析错误。
* 优先用 defineProperty,回退到普通赋值,两者都失败就静默跳过:
* 丢掉的只是被重写的栈帧,绝不该丢掉诊断信息。
*/
functionsafeSetStack(error: unknown,stack: string): void {
if(typeoferror!=='object'||error===null||typeofstack!=='string')return
consttarget=errorasError
try{
Object.defineProperty(target,'stack', { value:stack, writable:true, configurable:true})
return
}catch{
/* 被封冻的 Error 同样会拒绝 defineProperty */
}
try{
target.stack=stack
}catch{
/* 只读 stack 的代价仅是少一帧重写,永远不该是丢掉诊断 */
}
}
resolver.ts· 调用点
const stack = error.stack
error.message = message
if (stack !== undefined) safeSetStack(error, stack.replace(originalMessage, message))
加固范围覆盖TypeScript 源码 + 两处已构建的lib/index.js产物——因为实际运行时加载的是构建产物,只改源码不会生效。
固化成可重复执行的脚本
整个修复被写成一个幂等脚本scripts/fix-plugin-red-metadata.ps1,支持预演:
预演:只打印将要修改的内容,不落盘
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\fix-plugin-red-metadata.ps1 -DryRun
正式执行
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\fix-plugin-red-metadata.ps1
脚本特性:幂等(重复运行全部[skip])、可预演、写盘前校验 JSON、带自愈(能识别并还原历史误改)。
05 验证:用事实收尾
1. 资源解析验证
写了独立脚本,用 Node 自身的解析器逐条验证 8 个包 × 3 个关键资源(locale/en.json、locale/zh.json、package.json):
OK @deepseek-ai/dsh-persona locale/en.json
OK @deepseek-ai/dsh-persona locale/zh.json
OK @deepseek-ai/dsh-persona package.json
OK @deepseek-ai/dsh-agent-instructions locale/en.json
...
OK @deepseek-ai/dsh-skill-filesystem package.json
总计:24 / 24 解析成功,0 失败
2. 复现实验:逐字还原原报错,并证明已被消除
为了确认根因判断无误,我在 Node 22.22.2 下造了一个只读stack的实例,分别用「原始写法」和「safeSetStack 写法」去改写,结果如下:
对照实验输出(修复前写法)
PLAIN_ASSIGN = Cannot assign to read only property 'stack' of object
'Error: Package subpath './locale/en.json' is not defined by exports'
对照实验输出(safeSetStack 写法)
SAFESETSTACK = no-throw
MESSAGE_PRESERVED = true
原报错逐字符复现成功——同一句Cannot assign to read only property 'stack' of object 'Error: Package subpath ...'。这坐实了第 2 节的判断。而safeSetStack不仅不抛异常,MESSAGE_PRESERVED = true说明真实诊断信息被完整保留了下来。
3. 构建产物语法检查 + 幂等复验
两处被修改的lib/index.js均通过语法解析,无破坏。
修复脚本二次运行:全部[skip],确认幂等,不会重复写入。
06 踩坑记录
这次修复过程本身踩了不少坑,其中几个挺有代表性,记下来。
坑 1:PowerShell 5.1 把 UTF-8 脚本当 ANSI 读
脚本里有中文,保存为「UTF-8 无 BOM」后,Windows PowerShell 5.1 会按系统 ANSI 码页解析,中文变成乱码,引号配对也因此错乱,脚本直接语法报错。
解决办法是强制写成 UTF-8带 BOM:
$p = "E:\...\scripts\fix-plugin-red-metadata.ps1"
$t = [IO.File]::ReadAllText($p, [Text.Encoding]::UTF8)
[IO.File]::WriteAllText($p, $t, (New-Object Text.UTF8Encoding($true)))
同样地,Get-Content -Raw / Set-Content 默认也按 ANSI 处理,读写 UTF-8 文本会让中文变乱码。处理 HTML / CSS / JSON / Markdown 时,统一用 [IO.File]::ReadAllText + [IO.File]::WriteAllText 指定 UTF8。
坑 2:\s* 是贪婪的,会把换行也吃掉
想匹配「逗号 + 换行 + 缩进 + 右括号」,写了,\s*\r?\n\s*\]。结果永远不会匹配——因为第一个\s*已经贪婪地把换行一并消费了,后面的\r?\n就无字符可匹配。
正确写法是把「水平空白」和「换行」显式区分开:
错:,\s*\r?\n\s*\]
对:[ \t]*,\r?\n[ \t]*\]
坑 3:[regex]::Replace 的 MatchEvaluator 作用域陷阱
在Regex.Replace传入的 scriptblock 里做计数与组引用,踩了两个雷:
直接用$script:hits++有时不生效,计数器恒为 0;
用$m.Groups[1].Value在某些包装下解析错位,甚至让局部变量退化成「整个匹配串」,于是替换结果出现了safeSetStack(stack, stack.replace(...))这种把error参数搞丢的畸形代码。
可靠的做法是:把替换逻辑抽成一个带类型标注的独立函数,在 scriptblock 内显式param($m)调用,并用return拼接结果字符串,而不是依赖内联表达式和隐式作用域。
坑 4(最贵的那个):惰性通配 .*? 越界吞掉代码
为了「移除已存在的 helper 函数」,写了这样一条正则:
(?s)/\*\*\r?\n \* Rewrite an error's stack.*?\r?\n\}\r?\n\r?\n
本意是匹配一个 JSDoc 注释块加函数体。但惰性通配只保证「尽量短」,一旦中途出现第一个满足后续条件的}+ 空行,它就会在那里收尾——结果把紧随其后的整个throwWithImporter函数(约 1700 字符)一起删掉了。
结论:不要用惰性通配去删除代码块。
要么让锚点足够具体(匹配到函数名和签名),要么干脆别做这种「删除+重建」的操作——本例最后的选择是完全放弃删除逻辑,改为只重写调用点、helper 保持幂等插入,风险归零。
坑 5:修 JSON 别用正则
这是本次的核心教训。用正则去改 JSON 结构,等于在不知道括号配对关系的情况下做手术。第一个版本正是因此把exports块的尾部切碎了——我造成的破坏一度比原 bug 更大。
最终的原则:
读结构用 JSON 解析器,不用正则;
写结构时如果必须保留原格式,就把「定位边界」和「重建内容」分开,内容来自解析结果;
落盘前必校验:写完先ConvertFrom-Json验一遍,不合法就中止,绝不写入半成品。
07 附:几条常见但不对的建议
排查期间参考了一些外部建议,事后核对下来有几条与实际情况不符,一并列出,避免后来者再走弯路。
建议是否成立实际情况升级 Node 版本就能解决不成立真凶是 package.json 非法 JSON,与 Node 版本无关。但 Node 22.19+ 确实是「假报错」出现的成因之一,两者容易混淆。插件缺少 dsh 清单字段不成立与本次报错无关。这几个包本身能被正常识别为插件。用社区工具 dsh-fix 修复不成立那是独立的第三方工具,修的不是这个缺陷;本例用一条本地脚本即可闭环。插件缺少 locale/en.json 文件半对文件一直存在且内容完整。真正的问题是 exports 通道不通,按「补文件」处理会新增重复文件而报错依旧。plugin-package-inventory 会导致每次模型请求失败不成立仓库中对应的包名是 dsh-plugin-package-inventory-deepseek,本身正常,与本次报红无关。
08 结论
把这次排查压缩成几条可复用的经验:
多个对象报出完全相同的错误时,先怀疑报错机制,而不是这些对象。本例中 8 个插件「同病」的真正原因是错误处理路径上的次生异常,而非 8 个独立缺陷。
「找不到文件」和「不允许访问文件」是两种错误,消息可能长得很像。本例报的是路径未导出(通道问题),而文件其实一直都在。
运行时的 Node 版本升级会悄悄改变错误处理的语义。Error.prototype.stack从可写变只读,让一行长期无害的赋值突然变成 throw 源。
修 JSON 用解析器,不用正则。结构操作交给结构工具;落盘前一定要有校验关卡。
只改源码不够,要覆盖构建产物。实际执行的是lib/下编译后的代码,源码修复不会自动生效。
后续注意事项
当前node_modules下这些包指向 workspace 源包的符号链接,改源包即生效,无需重装。
如果之后执行了pnpm install重装依赖,建议重新运行一次修复脚本(如果依赖是从坏模板重新生成的)。
DSH 处于快速迭代的预览阶段,插件版本最好显式锁定(dsh plugin add @),避免自动更新引入新的不兼容。
最终结果
8 个插件包的元数据全部恢复正常,24/24 资源解析成功,同时移除了「真实错误被假报错掩盖」这一隐患。重启服务后插件列表不再标红。
复现与验证环境:deepseek-harness 0.1.7 · Node.js 22.22.2 · Windows 11。文中所有对照实验结论均在本机实测得出。