DeepSeek Harness:DSH plugin 包元信息错误,stack 为何只读

故障排查发布于 2026-10-03作者: DeepSeek Plugin 插件市场
DeepSeek HarnessDSH插件元信息error.stack模块钩子tsxESM loaderstrict mode
源码/tsx 启动时插件列表给本该正常的插件标红「包元信息错误」,安装版却一切正常。真凶是解析器重写 error.stack 时踩到了只读属性,而 stack 变只读是因为解析走了注册过的模块钩子链。本文给测量矩阵与两种修法。

升级到 0.1.7-alpha.1 后,插件列表把 @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" …'——而这个插件的元信息其实是好的。 这条链路有两层:第一层,package-meta.ts 把 <包>/locale/en.json 当可选资源读,dsh-persona 没有这个子路径导出,Node 正常抛 ERR_PACKAGE_PATH_NOT_EXPORTED,本该被 missingResource() 识别成「资源不存在」并回退到 package.json 里的名称与描述(#7518);第二层,解析器 resolver.ts 为了把报错里的 importer 路径换成可读形式,去重写 error.stack,而在 Node v24.11.0 上这一步抛了 TypeError,新错误顶掉了带 code 的原错误,于是兜底文案把它报成了元信息损坏。最关键的一问是「stack 到底什么时候只读」——测量给出了反直觉的答案:不取决于 Node 版本,取决于解析有没有经过注册过的模块钩子链;一个只做 return next(...) 的空 loader 就够。 这也正好解释了「源码/tsx 启动坏、安装版正常」的环境分裂。

先分诊:环境分叉本身就是最大的线索

一句话:出错与不出错的差别不在插件、不在 Node 版本,而在「启动时有没有注册模块钩子」——pnpm dsh 是 node --import tsx/esm,安装版是纯 node。

启动方式是否注册模块钩子现象
pnpm dsh(源码)是(--import tsx/esm)无 locale/en.json 的插件被标红「包元信息错误」
已安装的桌面/CLI否(纯 node)一切正常

如果你的团队里只有跑源码的人报错、用安装版的人说没问题,别急着怀疑「哪台机器坏了」,先把钩子链这个变量拎出来。

机制:触发链、stack 可写性与钩子链

触发链:形状是确定的,属性是可变的

一句话:五步链条里,前四步都是设计内行为,第五步才是缺陷——而第五步能否发生,取决于 stack 是不是只读。

  1. 插件列表读可选资源。 packages/boot/app-boot/src/package-meta.ts:151:

    ts
    const englishPath = optionalResourcePath(`${specifier}/locale/en.json`, parentURL)
    
  2. 插件没有该子路径导出。 dsh-persona 既没有 locale 目录,也没有在 exports 里声明它——于是 Node 抛 ERR_PACKAGE_PATH_NOT_EXPORTED。

  3. 按设计这应当被忽略。 package-meta.ts:59-63 的 missingResource() 明确把该 code 归入「资源不存在」,:69 返回 undefined,正常回退到 package.json 里的名称与描述。所以这一步本身不该报错。

  4. 解析器重写 stack。 packages/boot/app-boot/src/profile-resolution/resolver.ts 为了让报错里的 importer 更可读,去改 error.stack:

    :665   if (stack !== undefined) error.stack = stack.replace(originalMessage, message)
    :688   if (stack !== undefined) error.stack = stack.replace(originalMessage, error.message)
    

    注意守卫是 if (stack !== undefined)——它守的是「读得到」,不是「写得进」。

  5. 新错误顶掉旧错误。 若该属性不可写,:665 抛 TypeError,并从 :667 的 throw error 冒出去。调用方再也拿不到那个带 code 的错误,package-meta.ts:65-72 于是把任何非 missingResource() 的东西丢进 :169-170 的兜底:

    ts
    } catch (error) {
      return { error: `Plugin metadata for ${specifier}: ${String(error)}` }
    }
    

    这就是界面上那句「包元信息错误」的来源。

链条的形状没有争议;争议只在第 5 步的前提——stack 是不是只读的。

关键分歧:stack 的可写性由「钩子链」决定,不由 Node 版本决定

一句话:不注册钩子时 stack 是带 setter 的 accessor(可写);一旦解析经过钩子链,它变成 { writable: false, configurable: true } 的 data 属性(不可写)——而且触发者是链本身,不是 tsx。

一位复核者先按 dsh-v0.1.7-alpha.1(c36a83ff6b)核对源码,确认了链路形状(package-meta.ts:151、missingResource()、兜底 catch 全部对得上),但复现不出「只读」:他用真实的 ERR_PACKAGE_PATH_NOT_EXPORTED 读属性描述符,两版 Node 上都是 own accessor 且带 setter,裸赋值不抛:

node v22.22.3 → code=ERR_PACKAGE_PATH_NOT_EXPORTED  {own:true, configurable:true, hasGetSet:true}  bareAssignment: OK
node v24.21.0 → code=ERR_PACKAGE_PATH_NOT_EXPORTED  {own:true, configurable:true, hasGetSet:true}  bareAssignment: OK

他还试了另外四种可能的来源——structuredClone(error)、跨 worker 边界的 error、Object.freeze(error)、Error.captureStackTrace()——stack 全部可写(accessor 的 setter 不受 writable / freeze 影响)。要得到那句 Cannot assign to read only property 'stack',需要有人显式 Object.defineProperty(err, 'stack', { value }) 且不带 writable: true。而在 packages/ 全树搜 defineProperty 与 'stack' 的组合,只命中测试文件。

测量矩阵给出了真正的那一维——钩子链:

runtimehook chainown stack严格模式赋值
Node 22.20.0无accessor(get/set)OK
Node 22.20.0tsx(--import tsx/esm)data,writable:false,configurable:trueTypeError
Node 22.20.0一个空 loader(--experimental-loader)data,writable:false同一个 TypeError
Node 22.20.0一个空 loader(--import + module.register())data,writable:false同一个 TypeError
Node 24.2.0无accessorOK
Node 24.2.0tsxdata,writable:false同一个 TypeError
Node 26.5.0无accessorOK
Node 26.5.0tsxaccessorOK
Node 26.5.0一个空 loader(register())data,writable:false同一个 TypeError

三条读数,一条比一条有用:

  1. tsx 不是原因。 那个空 loader 的全部代码就是 return next(specifier, context),它照样触发。tsx 只是「恰好挂在链上」的一种常见方式。
  2. Node 版本不是原因。 Node 26 上同样用 tsx 反而不复现,而显式注册 loader 仍然复现——可见决定变量是钩子链的有无。
  3. 环境分裂有了解释。 pnpm dsh 是 node --import tsx/esm(在链上),安装版是纯 node(不在链上)。

机制:钩子链把解析放到独立线程执行,错误跨过这条边界后被 Node 的错误(反)序列化重新构造,于是 stack 被装成 own data 属性、不带 writable: true(保留 configurable: true)。这也回答了「为什么仓库里搜不到那个 defineProperty」——属性在 DSH 的代码看到这个 error 之前就已经被冻结了。

两个容易误判的细节:探针陷阱与行号核对

一个会让你误判的探针陷阱:node -e 是 sloppy mode

一句话:在非严格模式下给不可写属性赋值是静默失败、不抛错的;所以用 node -e 测「赋值会不会抛」会给出假阴性,把正确的报告当成「根因不在这里」而丢掉。

复核者原打算用一句话探针来判定:

sh
node -e "import('@deepseek-ai/dsh-persona/locale/en.json').catch(e=>{… try{e.stack='x';console.log('assignment OK')}catch(x){console.log('THREW:',x.message)}})"

问题在于 node -e 跑的是 CommonJS 脚本——sloppy mode。实测在受影响的配置上,它能同时打印出互相矛盾的两件事:

Node 22.20.0 + tsx,先读描述符再赋值:
  code=ERR_PACKAGE_PATH_NOT_EXPORTED own=true data(writable=false) configurable=true
  bare assignment: OK          <-- 不抛,因为这是 .cjs

也就是说,一个真正受影响的报告人会同时看到 writable:false 与 assignment OK,而判定规则「assignment OK ⇒ 根因在别处」会把这份正确的报告丢进垃圾桶。DSH 的 resolver.ts 是 ESM(严格模式),所以在它那里会真抛。

可靠判别用描述符,不用赋值结果。 想同时看赋值行为,就把探针放进 .mjs:

sh
cat > probe.mjs <<'EOF'
try { await import('@deepseek-ai/dsh-persona/locale/en.json') } catch (e) {
  const d = Object.getOwnPropertyDescriptor(e, 'stack')
  let assign = 'OK'; try { e.stack = 'x' } catch (x) { assign = 'THREW: ' + x.message }
  console.log(process.version, e.code, JSON.stringify(d && { own: true, writable: d.writable, accessor: !!(d.get || d.set), configurable: d.configurable }), assign)
}
EOF
node probe.mjs                  # expect accessor / OK
node --import tsx/esm probe.mjs # expect data writable:false / THREW

行号核对:报告人读的是源码,不是产物

一句话::655(function throwWithImporter)与 :665(赋值)在两个 tag 上都正确,第二处重写点是 :688——行号能对上,说明报告人读的是源码。

sh
git show dsh-v0.1.7-alpha.1:packages/boot/app-boot/src/profile-resolution/resolver.ts | grep -n 'error.stack = '
# 665:    if (stack !== undefined) error.stack = stack.replace(originalMessage, message)
# 688:    if (stack !== undefined) error.stack = stack.replace(originalMessage, error.message)
git show dsh-v0.1.7-alpha.2:packages/boot/app-boot/src/profile-resolution/resolver.ts | grep -n 'error.stack = '   # 同样两行
git show c36a83ff6b:packages/boot/app-boot/src/profile-resolution/resolver.ts | grep -n 'error.stack = '        # 同样两行

顺带一提:alpha.1、alpha.2、以及复核者的提交上两行完全相同。而除了 resolver.ts,生产代码里还有两处同形写入,且都在改「跨域/跨进程过来的 error」:

  • packages/extensions/cordis-client-runner/src/client/index.ts:155
  • packages/session/session-persistence-jsonl/src/migration-verifier.ts:168

其中 CJS 那一处(throwWithoutCjsAnchor)更可能是第二个发生点,因为 MODULE_NOT_FOUND 错误同样会跨过钩子边界。

修法与影响面:对症 / 治本 / 不只是内置插件

修法一(对症):把 stack 重写改成「按描述符分支」

一句话:不可写时不要裸赋值,而是用 Object.defineProperty 带 value 覆写(configurable: true 让这可行);仍然失败就吞掉——因为改不出 stack 只是少点外观信息,而让它抛错会顶掉原始错误、丢掉 code。

ts
function rewriteStack(error: Error, original: string, message: string): void {
  const stack = error.stack
  if (stack === undefined) return
  const rewritten = stack.replace(original, message)
  const descriptor = Object.getOwnPropertyDescriptor(error, 'stack')
  try {
    if (descriptor?.get !== undefined) error.stack = rewritten
    else if (descriptor !== undefined) Object.defineProperty(error, 'stack', { ...descriptor, value: rewritten })
    else error.stack = rewritten
  } catch {
    // A stack we cannot rewrite is cosmetic. Letting this throw replaces the
    // error and discards its `code`, which is how the plugin page loses the
    // difference between "no locale file" and "the resolver failed".
  }
}

三处细节都有理由:

  1. descriptor?.get !== undefined 分支不能省。 accessor 描述符上带 value 去 defineProperty 会抛 Invalid property descriptor——所以「无条件 defineProperty」是不成立的。
  2. { ...descriptor, value: rewritten } 保留了 configurable: true。 正是它让覆写在被冻结的 data 属性上成功(已实测)。
  3. catch 是刻意的静默。 这里的取舍很明确:外观 vs. 语义,必须保语义。

修法二(治本):先按 code 分类,再决定要不要重写

一句话:missingResource() 全靠 code 判定,而 code 在整个 message/stack 重写过程中从不被改动**——所以要保证「到达 missingResource() 的必须是原始 error」,顺序应是先分类、再重写。**

讨论里对这条有一个重要澄清:「按 code 分类」与「加描述符守卫」是互补的,不是二选一。 因为可观测的失效不是 code 被改坏了(它从来没被改过),而是一个不同的 error 对象替换了原对象:TypeError 从 :667 的 throw error 冒出去,调用方根本收不到带 code 的那个错误。所以修好重写这一步对症状是充分的,而「重写前先分类」仍是更好的顺序。

值得写进注释的不变式:

到达 missingResource() 的,必须是最初那个 error。 因为该判定只认 code(package-meta.ts:59-63),其余一切都会被上报成元信息损坏。

这不只是内置插件的问题

这句话对插件作者尤其重要:只要你的包没有导出 locale/en.json,在源码/tsx 启动下就会看到同一条红色报错——你会被提示「你的包元信息坏了」,而那个包的元信息其实是好的。

所以出问题时的正确反应不是去补一个空的 locale/en.json(这只是把可选资源变成存在资源,掩盖了错误处理本身的缺陷),而是:

  1. 确认自己是否在钩子链上启动(pnpm dsh / --import tsx/esm);
  2. 短期用安装版查看插件列表;
  3. 关注/推动上游把 resolver.ts 的重写改成描述符感知、并按 code 分类。

排查注意事项

  1. 先看环境分叉。 「源码启动坏、安装版正常」几乎是钩子链有无的直接指纹。
  2. 不要用 node -e 判定属性可写性。 它是 CJS/sloppy mode,给不可写属性赋值静默失败、不抛错——会给出假阴性。
  3. 判定看描述符,不看赋值结果。 getOwnPropertyDescriptor 里的 writable 与 hasGetSet 才是可靠读数。
  4. 要测赋值就把探针放进 .mjs。 ESM 严格模式才会像 resolver.ts 那样真抛。
  5. 别把「Node 版本」当变量先做归纳。 同一版本下,注册不注册钩子会给出相反结果;先固定钩子链这个变量。
  6. 一个空 loader 就够触发。 排障时不要因为「我只用了 --experimental-loader、没用 tsx」就排除自己。
  7. 守卫 if (stack !== undefined) 守错了方向。 它保证的是「读得到」,不是「写得进」——这类「前置检查与操作前提不匹配」是同一个反模式。
  8. 修 stack 时要按描述符分支。 accessor 用赋值、data 属性用 defineProperty,且必须保留 configurable。
  9. 保语义优先于保外观。 重写失败应当吞掉;让它抛错会顶掉带 code 的原错误,把「可选资源缺失」误报成「包元信息损坏」。
  10. 记住同形写入点不止一处。 resolver.ts:665/:688、cordis-client-runner/src/client/index.ts:155、session-persistence-jsonl/src/migration-verifier.ts:168 都在写跨边界来的 error 的 stack。

来源

  • #7518 — 0.1.7.alpha1 macOS 插件说明报错(触发链路、源码核对、钩子链测量矩阵、sloppy mode 探针陷阱、描述符感知修法与不变式,均出自该讨论)
  • deepseek-ai/deepseek-harness(packages/boot/app-boot/src/package-meta.ts、packages/boot/app-boot/src/profile-resolution/resolver.ts、packages/extensions/cordis-client-runner/src/client/index.ts、packages/session/session-persistence-jsonl/src/migration-verifier.ts)

这条案例最值得记住的,是「同一个属性在不同执行路径下有不同的可写性」——而判据是模块钩子链,不是运行时版本。 如果你在维护 DSH 插件,或者自己写了任何在错误对象上做二次加工(改 message、改 stack、包一层)的代码,建议照这两条改:先按 code 之类的不变字段分类,再决定要不要动它;动之前读一次描述符,别假设它一定可写。至于排查,记住那个探针陷阱——node -e 里不抛错,不代表属性可写,它只说明你在 sloppy mode 里。核对插件元信息时,可在 DSH Plugin Hub 的插件市场页直接看到卡片标红与描述,便于对照现场。

DSH Plugin Hub 插件市场:插件卡片展示名称、版本、分类与描述,元信息读取失败时会在此标红

常见问题

为什么同一份插件,安装版正常,用源码/`pnpm dsh` 启动就给插件标红?

因为这两条启动路径的**模块解析方式不同**。pnpm dsh 等价于 node --import tsx/esm,解析走的是注册过的**模块钩子链**;安装版是纯 node、不注册任何钩子。只要解析经过钩子链,Node 会在跨线程重建错误对象时把 stack 装成**不可写的 own data 属性**,解析器那句 error.stack = … 于是在严格模式下抛 TypeError。所以「源码启动坏、安装版正常」不是玄学,而是钩子链的有无。

`error.stack` 到底是不是只读的?我看到有人复现不出来。

**取决于跑没跑过钩子链,不取决于 Node 版本。** 同一台机器、同一个 Node,不注册钩子时 stack 是带 setter 的 accessor(可写);注册钩子后变成 { writable: false, configurable: true } 的 data 属性(不可写)。而且**触发者是钩子链本身**——一个只写 return next(specifier, context) 的空 loader 就能复现,不是 tsx 做了什么。这也解释了为什么在 packages/ 里搜 defineProperty+stack 只命中测试文件:这个冻结是 Node 干的,不是仓库干的。

我用 `node -e "…e.stack='x'…"` 测试,结果显示赋值成功,是不是说明 stack 可写?

**这个探针会骗你。** node -e 跑的是 CommonJS 脚本,处于 **sloppy mode**;在非严格模式下给不可写属性赋值**静默失败、不抛错**。实测在受影响的配置上可以同时打印出 writable:false 和 assignment OK——两个结论自相矛盾。**可靠的判别是属性描述符**,而不是赋值结果;如果你确实想测赋值,请把代码放进 .mjs 文件(或加 'use strict'),因为 DSH 的 resolver.ts 是 ESM、严格模式,所以它会抛。

这个错误只影响内置插件吗?我自己写的插件会不会中招?

**会。** 只要你的插件没有导出 locale/en.json 这个子路径,在源码/tsx 启动下就会看到同一条红色报错——插件作者会看到「你的包元信息坏了」,而那个包的元信息其实是好的。触发条件不是「内置还是外部」,而是「有没有这个可选子路径导出」加上「解析是否经过钩子链」。

上游修了吗?我该改代码还是绕行?

讨论里给出的修法分两层,且被认为是**互补而非二选一**:①把重写 stack 改成「按描述符分支」,不可写时用 Object.defineProperty 带 value 覆写(configurable: true 使这可行),失败则吞掉——因为改不出 stack 只是少点外观信息,而让它抛错会**顶掉原始错误、丢掉 code**;②更稳的方向是**先按 code 分类、再决定要不要重写**,保证到达 missingResource() 的始终是带着原始 code 的那个 error。在你这条版本线上,短期绕行就是**用安装版启动**(不走钩子链)来查看插件列表。

相关术语

模块钩子链(module hook chain)
Node 的 ESM 自定义加载机制(`--experimental-loader`、`--import` + `module.register()`、或 tsx 这类基于它的工具)在解析阶段插入的一组钩子。关键副作用是:**解析被放到独立线程执行,错误对象跨线程边界后由 Node 重新构造**,`stack` 因此被装成不可写的 own data 属性。触发者是这条链**本身**,而非某个具体加载器。— https://github.com/deepseek-ai/deepseek-harness/discussions/7518
ERR_PACKAGE_PATH_NOT_EXPORTED
Node 在目标包存在、但其 `exports` 未声明被请求子路径时抛出的错误码。在这条链路里它是**正常且被期待**的:`package-meta.ts` 把 `${specifier}/locale/en.json` 当**可选**资源读,`missingResource()` 正是靠这个 `code` 判定「资源不存在」并回退到 `package.json` 里的名称与描述。— https://github.com/deepseek-ai/deepseek-harness/discussions/7518
property descriptor(属性描述符)
`Object.getOwnPropertyDescriptor()` 返回的属性形态。同一个 `stack` 可能有两种:带 `get`/`set` 的 **accessor**(可写),或带 `value` 与 `writable` 的 **data** 属性(钩子链下为 `writable:false, configurable:true`)。**判别可写性只能看描述符**,因为「赋值是否抛错」还受严格/非严格模式影响。— https://github.com/deepseek-ai/deepseek-harness/discussions/7518
sloppy mode(非严格模式)差异
CommonJS 脚本默认处于 sloppy mode。在此模式下,给不可写属性赋值**不会抛错,而是静默地什么都不做**;ESM 则默认严格模式,会抛 `TypeError: Cannot assign to read only property`。这就是 `node -e` 探针会给出假阴性、而 DSH 的 ESM 解析器会真抛错的原因。— https://github.com/deepseek-ai/deepseek-harness/discussions/7518

来源