夜雨聆风学习资料网

ARTICLE · 1065656

修复dsh插件报红包元信息错误:Plugin metadata for @deepseek-ai/dsh-x: TypeError: Cannot assign to read only property

修复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.jsonexports块尾部残留一组悬空键,整份文件是非法 JSON,导致pkg/locale/en.json无法解析。

关键发现locale/en.jsonlocale/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-personadsh-agent-instructionsdsh-tool-bashdsh-tool-pwsh

dsh-tool-fsdsh-tool-fs-searchdsh-tool-jobsdsh-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

writableundefined,只有 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.jsonlocale/zh.jsonpackage.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。文中所有对照实验结论均在本机实测得出。

相关学习资料