程序员扣棣头像
关注
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.7
Node.js22.22.2
系统Windows 11
日期2026-09-23

TL;DR

  1. 现象:8 个 @deepseek-ai/dsh-* 插件在插件管理里全部标红,报错文本完全相同。
  2. 假报错:Node 22.19+ 起 Error.prototype.stack 是只读 getter,DSH 的 throwWithImporter() 对它赋值时抛 TypeError,把真正的 ERR_PACKAGE_PATH_NOT_EXPORTED 顶掉了。
  3. 真根因:这 8 个包的 package.jsonexports 块尾部残留一组悬空键,整份文件是非法 JSON,导致 pkg/locale/en.json 无法解析。
  4. 关键发现locale/en.jsonlocale/zh.json 一直就存在且内容完整。缺的不是文件,是通道。
  5. 修复:重建单一 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(改动前)

function throwWithImporter(error, routedParent, parent): never {
  const originalMessage = error.message
  const message = `${originalMessage} imported from ${routedParent}`
  const stack = error.stack
  error.message = message
  if (stack !== undefined) error.stack = stack.replace(originalMessage, message)  // ← 问题在这行
  throw error
}

它先改写 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.jsonlocale/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 解析器,而不是正则

  1. 先用解析器读出原 exports 里的全部键值对(在非法 JSON 上做容错提取,丢弃无法归属的悬空项);
  2. 补上 "./locale/*.json": "./locale/*.json",重建为单一、闭合正确的块;
  3. 同时确保 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,回退到普通赋值,两者都失败就静默跳过:
 * 丢掉的只是被重写的栈帧,绝不该丢掉诊断信息。
 */
function safeSetStack(error: unknown, stack: string): void {
  if (typeof error !== 'object' || error === null || typeof stack !== 'string') return
  const target = error as Error
  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 结论

把这次排查压缩成几条可复用的经验:

  1. 多个对象报出完全相同的错误时,先怀疑报错机制,而不是这些对象。 本例中 8 个插件「同病」的真正原因是错误处理路径上的次生异常,而非 8 个独立缺陷。
  2. 「找不到文件」和「不允许访问文件」是两种错误,消息可能长得很像。 本例报的是路径未导出(通道问题),而文件其实一直都在。
  3. 运行时的 Node 版本升级会悄悄改变错误处理的语义。 Error.prototype.stack 从可写变只读,让一行长期无害的赋值突然变成 throw 源。
  4. 修 JSON 用解析器,不用正则。 结构操作交给结构工具;落盘前一定要有校验关卡。
  5. 只改源码不够,要覆盖构建产物。 实际执行的是 lib/ 下编译后的代码,源码修复不会自动生效。

后续注意事项

  • 当前 node_modules 下这些包指向 workspace 源包的符号链接,改源包即生效,无需重装。
  • 如果之后执行了 pnpm install 重装依赖,建议重新运行一次修复脚本(如果依赖是从坏模板重新生成的)。
  • DSH 处于快速迭代的预览阶段,插件版本最好显式锁定(dsh plugin add <name>@<version>),避免自动更新引入新的不兼容。

最终结果

8 个插件包的元数据全部恢复正常,24/24 资源解析成功,同时移除了「真实错误被假报错掩盖」这一隐患。重启服务后插件列表不再标红。


复现与验证环境:deepseek-harness 0.1.7 · Node.js 22.22.2 · Windows 11。文中所有对照实验结论均在本机实测得出。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/weixin_67874152/article/details/166481395

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--