DeepSeek Harness:DSH plugin 包元信息错误,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 是不是只读。
-
插件列表读可选资源。
packages/boot/app-boot/src/package-meta.ts:151:tsconst englishPath = optionalResourcePath(`${specifier}/locale/en.json`, parentURL) -
插件没有该子路径导出。
dsh-persona既没有locale目录,也没有在exports里声明它——于是 Node 抛ERR_PACKAGE_PATH_NOT_EXPORTED。 -
按设计这应当被忽略。
package-meta.ts:59-63的missingResource()明确把该code归入「资源不存在」,:69返回undefined,正常回退到package.json里的名称与描述。所以这一步本身不该报错。 -
解析器重写 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)——它守的是「读得到」,不是「写得进」。 -
新错误顶掉旧错误。 若该属性不可写,
: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' 的组合,只命中测试文件。
测量矩阵给出了真正的那一维——钩子链:
| runtime | hook chain | own stack | 严格模式赋值 |
|---|---|---|---|
| Node 22.20.0 | 无 | accessor(get/set) | OK |
| Node 22.20.0 | tsx(--import tsx/esm) | data,writable:false,configurable:true | TypeError |
| 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 | 无 | accessor | OK |
| Node 24.2.0 | tsx | data,writable:false | 同一个 TypeError |
| Node 26.5.0 | 无 | accessor | OK |
| Node 26.5.0 | tsx | accessor | OK |
| Node 26.5.0 | 一个空 loader(register()) | data,writable:false | 同一个 TypeError |
三条读数,一条比一条有用:
tsx不是原因。 那个空 loader 的全部代码就是return next(specifier, context),它照样触发。tsx 只是「恰好挂在链上」的一种常见方式。- Node 版本不是原因。 Node 26 上同样用 tsx 反而不复现,而显式注册 loader 仍然复现——可见决定变量是钩子链的有无。
- 环境分裂有了解释。
pnpm dsh是node --import tsx/esm(在链上),安装版是纯node(不在链上)。
机制:钩子链把解析放到独立线程执行,错误跨过这条边界后被 Node 的错误(反)序列化重新构造,于是 stack 被装成 own data 属性、不带 writable: true(保留 configurable: true)。这也回答了「为什么仓库里搜不到那个 defineProperty」——属性在 DSH 的代码看到这个 error 之前就已经被冻结了。
两个容易误判的细节:探针陷阱与行号核对
一个会让你误判的探针陷阱:node -e 是 sloppy mode
一句话:在非严格模式下给不可写属性赋值是静默失败、不抛错的;所以用 node -e 测「赋值会不会抛」会给出假阴性,把正确的报告当成「根因不在这里」而丢掉。
复核者原打算用一句话探针来判定:
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:
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——行号能对上,说明报告人读的是源码。
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:155packages/session/session-persistence-jsonl/src/migration-verifier.ts:168
其中 CJS 那一处(throwWithoutCjsAnchor)更可能是第二个发生点,因为 MODULE_NOT_FOUND 错误同样会跨过钩子边界。
修法与影响面:对症 / 治本 / 不只是内置插件
修法一(对症):把 stack 重写改成「按描述符分支」
一句话:不可写时不要裸赋值,而是用 Object.defineProperty 带 value 覆写(configurable: true 让这可行);仍然失败就吞掉——因为改不出 stack 只是少点外观信息,而让它抛错会顶掉原始错误、丢掉 code。
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".
}
}
三处细节都有理由:
descriptor?.get !== undefined分支不能省。 accessor 描述符上带value去defineProperty会抛Invalid property descriptor——所以「无条件defineProperty」是不成立的。{ ...descriptor, value: rewritten }保留了configurable: true。 正是它让覆写在被冻结的 data 属性上成功(已实测)。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(这只是把可选资源变成存在资源,掩盖了错误处理本身的缺陷),而是:
- 确认自己是否在钩子链上启动(
pnpm dsh/--import tsx/esm); - 短期用安装版查看插件列表;
- 关注/推动上游把
resolver.ts的重写改成描述符感知、并按code分类。
排查注意事项
- 先看环境分叉。 「源码启动坏、安装版正常」几乎是钩子链有无的直接指纹。
- 不要用
node -e判定属性可写性。 它是 CJS/sloppy mode,给不可写属性赋值静默失败、不抛错——会给出假阴性。 - 判定看描述符,不看赋值结果。
getOwnPropertyDescriptor里的writable与hasGetSet才是可靠读数。 - 要测赋值就把探针放进
.mjs。 ESM 严格模式才会像resolver.ts那样真抛。 - 别把「Node 版本」当变量先做归纳。 同一版本下,注册不注册钩子会给出相反结果;先固定钩子链这个变量。
- 一个空 loader 就够触发。 排障时不要因为「我只用了
--experimental-loader、没用 tsx」就排除自己。 - 守卫
if (stack !== undefined)守错了方向。 它保证的是「读得到」,不是「写得进」——这类「前置检查与操作前提不匹配」是同一个反模式。 - 修 stack 时要按描述符分支。 accessor 用赋值、data 属性用
defineProperty,且必须保留configurable。 - 保语义优先于保外观。 重写失败应当吞掉;让它抛错会顶掉带
code的原错误,把「可选资源缺失」误报成「包元信息损坏」。 - 记住同形写入点不止一处。
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 的插件市场页直接看到卡片标红与描述,便于对照现场。

常见问题
因为这两条启动路径的**模块解析方式不同**。pnpm dsh 等价于 node --import tsx/esm,解析走的是注册过的**模块钩子链**;安装版是纯 node、不注册任何钩子。只要解析经过钩子链,Node 会在跨线程重建错误对象时把 stack 装成**不可写的 own data 属性**,解析器那句 error.stack = … 于是在严格模式下抛 TypeError。所以「源码启动坏、安装版正常」不是玄学,而是钩子链的有无。
**取决于跑没跑过钩子链,不取决于 Node 版本。** 同一台机器、同一个 Node,不注册钩子时 stack 是带 setter 的 accessor(可写);注册钩子后变成 { writable: false, configurable: true } 的 data 属性(不可写)。而且**触发者是钩子链本身**——一个只写 return next(specifier, context) 的空 loader 就能复现,不是 tsx 做了什么。这也解释了为什么在 packages/ 里搜 defineProperty+stack 只命中测试文件:这个冻结是 Node 干的,不是仓库干的。
**这个探针会骗你。** 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
来源
- #7518 — 0.1.7.alpha1 macOS 插件说明报错· deepseek-ai(GitHub Discussions)
- deepseek-ai/deepseek-harness(源码仓库)· GitHub