DSH plugin metadata error: why error.stack is read-only
After upgrading to 0.1.7-alpha.1, the plugin list flags @deepseek-ai/dsh-persona in red as a "package metadata error", citing TypeError: Cannot assign to read only property 'stack' of object 'Error: Package subpath './locale/en.json' is not defined by "exports" …' — yet that plugin's metadata is actually fine. This chain has two layers. First, package-meta.ts reads <package>/locale/en.json as an optional resource; dsh-persona does not export that subpath, so Node normally throws ERR_PACKAGE_PATH_NOT_EXPORTED, which should be recognized by missingResource() as "resource does not exist" and fall back to the name and description in package.json (#7518). Second, the resolver resolver.ts, to make the importer path in the error readable, rewrites error.stack, and on Node v24.11.0 this step throws TypeError, the new error replaces the original one that carried code, so the fallback text reports it as corrupted metadata. The most critical question is "when exactly is stack read-only" — and measurement gives a counterintuitive answer: it does not depend on the Node version, it depends on whether resolution went through a registered module hook chain; an empty loader that only does return next(...) is enough. This also neatly explains the environment split of "breaks from source/tsx, fine when installed".
Triage first: the environment fork is itself the biggest clue
In one sentence: the difference between failing and not failing is not the plugin, not the Node version, but "whether a module hook is registered at startup" — pnpm dsh is node --import tsx/esm, the installed build is plain node.
| Startup method | Module hook registered? | Symptom |
|---|---|---|
pnpm dsh (source) | yes (--import tsx/esm) | plugins without locale/en.json are flagged red as "package metadata error" |
| installed desktop/CLI | no (plain node) | everything is fine |
If in your team only those running from source report an error while those on the installed build say it is fine, do not rush to suspect "which machine is broken" — pull out the hook chain variable first.
Mechanism: the trigger chain, stack writability, and the hook chain
The trigger chain: the shape is fixed, the property is the variable
In one sentence: of the five steps, the first four are by-design behavior and the fifth is the defect — and whether the fifth can happen depends on whether stack is read-only.
-
The plugin list reads an optional resource.
packages/boot/app-boot/src/package-meta.ts:151:tsconst englishPath = optionalResourcePath(`${specifier}/locale/en.json`, parentURL) -
The plugin does not export that subpath.
dsh-personahas neither alocaledirectory nor a declaration for it inexports— so Node throwsERR_PACKAGE_PATH_NOT_EXPORTED. -
By design this should be ignored.
missingResource()atpackage-meta.ts:59-63explicitly classifies thatcodeas "resource does not exist",:69returnsundefined, and it normally falls back to the name and description inpackage.json. So this step itself should not error. -
The resolver rewrites the stack.
packages/boot/app-boot/src/profile-resolution/resolver.ts, to make the importer in the error more readable, modifieserror.stack::665 if (stack !== undefined) error.stack = stack.replace(originalMessage, message) :688 if (stack !== undefined) error.stack = stack.replace(originalMessage, error.message)Note the guard is
if (stack !== undefined)— it guards "can read", not "can write". -
The new error replaces the old one. If that property is non-writable,
:665throwsTypeErrorand it escapes via thethrow errorat:667. The caller can no longer get the error that carriedcode, sopackage-meta.ts:65-72throws anything that is notmissingResource()into the fallback at:169-170:ts} catch (error) { return { error: `Plugin metadata for ${specifier}: ${String(error)}` } }That is the source of the "package metadata error" shown in the UI.
The shape of the chain is not in dispute; the dispute is only over step 5's precondition — is stack read-only.
The key disagreement: stack's writability is decided by the hook chain, not the Node version
In one sentence: without hooks registered stack is an accessor with a setter (writable); once resolution goes through a hook chain it becomes a { writable: false, configurable: true } data property (non-writable) — and the trigger is the chain itself, not tsx.
One verifier first checked the source against dsh-v0.1.7-alpha.1 (c36a83ff6b) and confirmed the chain's shape (package-meta.ts:151, missingResource(), the fallback catch all line up), but could not reproduce "read-only": reading the property descriptor with a real ERR_PACKAGE_PATH_NOT_EXPORTED, on both Node versions it was an own accessor with a setter, and a bare assignment did not throw:
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
They also tried four other possible sources — structuredClone(error), an error crossing a worker boundary, Object.freeze(error), Error.captureStackTrace() — and stack was writable in all of them (an accessor's setter is unaffected by writable / freeze). To get that Cannot assign to read only property 'stack', someone must explicitly Object.defineProperty(err, 'stack', { value }) without writable: true. And a repository-wide search of packages/ for the combination of defineProperty and 'stack' hits only test files.
The measurement matrix produced the real dimension — the hook chain:
| runtime | hook chain | own stack | strict-mode assignment |
|---|---|---|---|
| Node 22.20.0 | none | accessor (get/set) | OK |
| Node 22.20.0 | tsx (--import tsx/esm) | data, writable:false, configurable:true | TypeError |
| Node 22.20.0 | an empty loader (--experimental-loader) | data, writable:false | the same TypeError |
| Node 22.20.0 | an empty loader (--import + module.register()) | data, writable:false | the same TypeError |
| Node 24.2.0 | none | accessor | OK |
| Node 24.2.0 | tsx | data, writable:false | the same TypeError |
| Node 26.5.0 | none | accessor | OK |
| Node 26.5.0 | tsx | accessor | OK |
| Node 26.5.0 | an empty loader (register()) | data, writable:false | the same TypeError |
Three readings, each more useful than the last:
tsxis not the cause. The whole body of that empty loader isreturn next(specifier, context), and it triggers just the same. tsx is merely one common way of "happening to sit on the chain".- The Node version is not the cause. On Node 26, using tsx likewise does not reproduce, while explicitly registering a loader still does — showing the deciding variable is the presence of the hook chain.
- The environment split is explained.
pnpm dshisnode --import tsx/esm(on the chain), the installed build is plainnode(not on the chain).
Mechanism: the hook chain runs resolution on a separate thread, and after the error crosses that boundary Node's error (de)serialization reconstructs it, so stack is installed as an own data property without writable: true (keeping configurable: true). This also answers "why can't I find that defineProperty in the repo" — the property is already frozen before DSH's code ever sees the error.
Two details that easily mislead: the probe trap and the line-number check
A probe trap that will mislead you: node -e is sloppy mode
In one sentence: in non-strict mode, assigning to a non-writable property fails silently without throwing; so using node -e to test "does the assignment throw" gives a false negative and discards a correct report as "the root cause is elsewhere".
The verifier originally intended to decide with a one-line probe:
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)}})"
The problem is that node -e runs a CommonJS script — sloppy mode. Measured on the affected configuration, it can print two contradictory things at once:
Node 22.20.0 + tsx, read the descriptor then assign:
code=ERR_PACKAGE_PATH_NOT_EXPORTED own=true data(writable=false) configurable=true
bare assignment: OK <-- does not throw, because this is .cjs
That is, a reporter who is genuinely affected would see both writable:false and assignment OK, and the decision rule "assignment OK ⇒ the root cause is elsewhere" would throw that correct report in the trash. DSH's resolver.ts is ESM (strict mode), so it really throws there.
Judge reliably with the descriptor, not the assignment result. If you also want to observe the assignment behavior, put the probe in a .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
Line-number check: the reporter read the source, not the artifact
In one sentence: :655 (function throwWithImporter) and :665 (the assignment) are correct on both tags, and the second rewrite point is :688 — the line numbers line up, which shows the reporter read the source.
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 = ' # the same two lines
git show c36a83ff6b:packages/boot/app-boot/src/profile-resolution/resolver.ts | grep -n 'error.stack = ' # the same two lines
Incidentally: on alpha.1, alpha.2, and the verifier's commit the two lines are identical. And besides resolver.ts, there are two more same-shaped writes in production code, both of which touch "an error that came across a domain/process boundary":
packages/extensions/cordis-client-runner/src/client/index.ts:155packages/session/session-persistence-jsonl/src/migration-verifier.ts:168
The CJS one (throwWithoutCjsAnchor) is more likely the second occurrence point, because a MODULE_NOT_FOUND error also crosses the hook boundary.
Fixes and scope: symptomatic / root cause / not just built-in plugins
Fix one (symptomatic): make the stack rewrite branch on the descriptor
In one sentence: when non-writable, do not assign bare; use Object.defineProperty with value to overwrite (configurable: true makes that possible); if it still fails, swallow it — because a stack you cannot rewrite is just a cosmetic loss, whereas letting it throw replaces the original error and discards its 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".
}
}
All three details have a reason:
- The
descriptor?.get !== undefinedbranch cannot be omitted. CallingdefinePropertywith avalueon an accessor descriptor throwsInvalid property descriptor— so "unconditionaldefineProperty" does not hold. { ...descriptor, value: rewritten }preservesconfigurable: true. That is exactly what lets the overwrite succeed on a frozen data property (measured).- The
catchis deliberate silence. The trade-off here is clear: appearance versus semantics, and semantics must win.
Fix two (root cause): classify by code first, then decide whether to rewrite
In one sentence: missingResource() decides entirely on code, and code is never changed throughout the message/stack rewrite — so guaranteeing "what reaches missingResource() must be the original error" means the order should be classify first, rewrite second.
The discussion makes one important clarification about this: "classify by code" and "add descriptor guards" are complementary, not either-or. The observable failure is not code being corrupted (it never is) but a different error object replacing the original: the TypeError escapes via the throw error at :667, and the caller simply never receives the error carrying code. So fixing the rewrite step is sufficient for the symptom, while "classify before rewriting" is still the better order.
An invariant worth writing into a comment:
What reaches
missingResource()must be the original error. Because that decision recognizes onlycode(package-meta.ts:59-63), and everything else gets reported as corrupted metadata.
This is not only a built-in plugin problem
This matters especially to plugin authors: as long as your package does not export locale/en.json, you will see the same red error when starting from source/tsx — you are told "your package metadata is broken" when that package's metadata is actually fine.
So the correct reaction when it happens is not to add an empty locale/en.json (that merely turns an optional resource into an existing one, masking the defect in the error handling itself), but to:
- Confirm whether you are starting on the hook chain (
pnpm dsh/--import tsx/esm); - Use the installed build in the short term to view the plugin list;
- Follow/push upstream to make
resolver.ts's rewrite descriptor-aware and classify bycode.
Troubleshooting notes
- Look at the environment fork first. "Breaks from source, fine when installed" is almost a direct fingerprint of the presence of the hook chain.
- Do not use
node -eto judge property writability. It is CJS/sloppy mode, and assigning to a non-writable property fails silently without throwing — it gives a false negative. - Judge by the descriptor, not by the assignment result. The
writableandhasGetSetingetOwnPropertyDescriptorare the reliable readings. - To test assignment, put the probe in a
.mjs. Only ESM strict mode really throws the wayresolver.tsdoes. - Do not make "the Node version" the variable you generalize on first. On the same version, registering hooks or not gives opposite results; fix the hook chain variable first.
- An empty loader is enough to trigger it. When troubleshooting, do not rule yourself out just because "I only used
--experimental-loader, not tsx". - The guard
if (stack !== undefined)guards the wrong direction. It guarantees "can read", not "can write" — this class of "the precondition check does not match the operation's precondition" is the same anti-pattern. - Branch on the descriptor when fixing the stack. Use assignment for an accessor,
definePropertyfor a data property, and preserveconfigurable. - Preserving semantics beats preserving appearance. A failed rewrite should be swallowed; letting it throw replaces the original error carrying
codeand misreports "an optional resource is missing" as "the package metadata is corrupted". - Remember there is more than one same-shaped write point.
resolver.ts:665/:688,cordis-client-runner/src/client/index.ts:155, andsession-persistence-jsonl/src/migration-verifier.ts:168all write thestackof an error that came across a boundary.
The most memorable point of this case is that "the same property has different writability under different execution paths" — and the deciding factor is the module hook chain, not the runtime version. If you maintain DSH plugins, or have written any code that post-processes an error object (changing the message, changing the stack, wrapping it), consider adopting these two rules: classify by an invariant field such as code first, then decide whether to touch it; read the descriptor once before touching it, and do not assume it is writable. As for troubleshooting, remember that probe trap — not throwing inside node -e does not mean the property is writable; it only means you are in sloppy mode. To cross-check plugin metadata, the plugin market page in DSH Plugin Hub shows the red flag and description directly.

Source: Discussion #7518, deepseek-ai/deepseek-harness.
FAQ
Because the two startup paths **resolve modules differently**. pnpm dsh is equivalent to node --import tsx/esm, so resolution goes through a registered **module hook chain**; the installed build is plain node with no hooks registered. As long as resolution passes through the hook chain, Node rebuilds the error object across threads and installs stack as a **non-writable own data property**, so the resolver's error.stack = … throws TypeError under strict mode. So "breaks from source, fine when installed" is not mysticism — it is the presence or absence of the hook chain.
**It depends on whether the hook chain ran, not on the Node version.** On the same machine with the same Node, without hooks registered stack is an accessor with a setter (writable); with hooks registered it becomes a data property of { writable: false, configurable: true } (non-writable). And **the trigger is the hook chain itself** — an empty loader whose entire body is return next(specifier, context) reproduces it; tsx is not doing anything special. This also explains why searching packages/ for defineProperty + stack only hits test files: that freeze is done by Node, not by the repo.
**That probe lies to you.** node -e runs a CommonJS script, which is in **sloppy mode**; in non-strict mode, assigning to a non-writable property **fails silently without throwing**. Measured, on the affected configuration you can print both writable:false and assignment OK at the same time — two contradictory conclusions. **The reliable test is the property descriptor**, not the assignment result; if you do want to test assignment, put the code in a .mjs file (or add 'use strict'), because DSH's resolver.ts is ESM and strict, so it really throws.
**Yes.** As long as your plugin does not export the subpath locale/en.json, you will see the same red error when starting from source/tsx — the plugin author is told "your package metadata is broken" when that package's metadata is actually fine. The trigger is not "built-in versus external" but "does it have this optional subpath export" combined with "does resolution pass through the hook chain".
The fixes offered in the discussion are two layers and are considered **complementary rather than either-or**: ① change the stack rewrite to "branch on the descriptor", and when non-writable use Object.defineProperty with value to overwrite (configurable: true makes that possible), swallowing on failure — because a stack you cannot rewrite is just a cosmetic loss, whereas letting it throw **replaces the original error and discards its code**; ② the more robust direction is to **classify by code first, then decide whether to rewrite**, guaranteeing that what reaches missingResource() is always the error carrying the original code. On your version line, the short-term workaround is to **start with the installed build** (no hook chain) to view the plugin list.
Related Terms
- module hook chain
- The set of hooks Node's ESM customization mechanism (`--experimental-loader`, `--import` + `module.register()`, or tools built on it like tsx) inserts at the resolution stage. The key side effect: **resolution is run on a separate thread, and after an error object crosses that thread boundary Node reconstructs it**, so `stack` is installed as a non-writable own data property. The trigger is the chain **itself**, not any particular loader.— https://github.com/deepseek-ai/deepseek-harness/discussions/7518
- ERR_PACKAGE_PATH_NOT_EXPORTED
- The error code Node throws when the target package exists but its `exports` does not declare the requested subpath. In this chain it is **normal and expected**: `package-meta.ts` reads `${specifier}/locale/en.json` as an **optional** resource, and `missingResource()` uses precisely this `code` to decide "the resource does not exist" and fall back to the name and description in `package.json`.— https://github.com/deepseek-ai/deepseek-harness/discussions/7518
- property descriptor
- The property shape returned by `Object.getOwnPropertyDescriptor()`. The same `stack` may take two forms: an **accessor** with `get`/`set` (writable), or a **data** property with `value` and `writable` (`writable:false, configurable:true` under the hook chain). **Writability can only be judged from the descriptor**, because "whether the assignment throws" is also governed by strict versus non-strict mode.— https://github.com/deepseek-ai/deepseek-harness/discussions/7518
- sloppy mode difference
- CommonJS scripts are in sloppy mode by default. In that mode, assigning to a non-writable property **does not throw; it silently does nothing**; ESM is strict by default and throws `TypeError: Cannot assign to read only property`. This is why the `node -e` probe gives a false negative while DSH's ESM resolver genuinely throws.— https://github.com/deepseek-ai/deepseek-harness/discussions/7518
Sources
- #7518 — 0.1.7.alpha1 macOS plugin description error· deepseek-ai (GitHub Discussions)
- deepseek-ai/deepseek-harness (source repository)· GitHub