DSH plugin metadata error: why error.stack is read-only

TroubleshootingPublished 2026-10-03Author: DeepSeek Plugin Market
DeepSeek HarnessDSHplugin metadataerror.stackmodule hookstsxESM loaderstrict mode
From source or tsx, healthy plugins get a metadata error, while installed builds are fine: the parser rewrites error.stack on a read-only property.

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 methodModule hook registered?Symptom
pnpm dsh (source)yes (--import tsx/esm)plugins without locale/en.json are flagged red as "package metadata error"
installed desktop/CLIno (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.

  1. The plugin list reads an optional resource. packages/boot/app-boot/src/package-meta.ts:151:

    ts
    const englishPath = optionalResourcePath(`${specifier}/locale/en.json`, parentURL)
    
  2. The plugin does not export that subpath. dsh-persona has neither a locale directory nor a declaration for it in exports — so Node throws ERR_PACKAGE_PATH_NOT_EXPORTED.

  3. By design this should be ignored. missingResource() at package-meta.ts:59-63 explicitly classifies that code as "resource does not exist", :69 returns undefined, and it normally falls back to the name and description in package.json. So this step itself should not error.

  4. The resolver rewrites the stack. packages/boot/app-boot/src/profile-resolution/resolver.ts, to make the importer in the error more readable, modifies error.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".

  5. The new error replaces the old one. If that property is non-writable, :665 throws TypeError and it escapes via the throw error at :667. The caller can no longer get the error that carried code, so package-meta.ts:65-72 throws anything that is not missingResource() 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:

runtimehook chainown stackstrict-mode assignment
Node 22.20.0noneaccessor (get/set)OK
Node 22.20.0tsx (--import tsx/esm)data, writable:false, configurable:trueTypeError
Node 22.20.0an empty loader (--experimental-loader)data, writable:falsethe same TypeError
Node 22.20.0an empty loader (--import + module.register())data, writable:falsethe same TypeError
Node 24.2.0noneaccessorOK
Node 24.2.0tsxdata, writable:falsethe same TypeError
Node 26.5.0noneaccessorOK
Node 26.5.0tsxaccessorOK
Node 26.5.0an empty loader (register())data, writable:falsethe same TypeError

Three readings, each more useful than the last:

  1. tsx is not the cause. The whole body of that empty loader is return next(specifier, context), and it triggers just the same. tsx is merely one common way of "happening to sit on the chain".
  2. 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.
  3. The environment split is explained. pnpm dsh is node --import tsx/esm (on the chain), the installed build is plain node (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:

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)}})"

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:

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

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.

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 = '   # 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:155
  • packages/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.

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".
  }
}

All three details have a reason:

  1. The descriptor?.get !== undefined branch cannot be omitted. Calling defineProperty with a value on an accessor descriptor throws Invalid property descriptor — so "unconditional defineProperty" does not hold.
  2. { ...descriptor, value: rewritten } preserves configurable: true. That is exactly what lets the overwrite succeed on a frozen data property (measured).
  3. The catch is 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 only code (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:

  1. Confirm whether you are starting on the hook chain (pnpm dsh / --import tsx/esm);
  2. Use the installed build in the short term to view the plugin list;
  3. Follow/push upstream to make resolver.ts's rewrite descriptor-aware and classify by code.

Troubleshooting notes

  1. Look at the environment fork first. "Breaks from source, fine when installed" is almost a direct fingerprint of the presence of the hook chain.
  2. Do not use node -e to judge property writability. It is CJS/sloppy mode, and assigning to a non-writable property fails silently without throwing — it gives a false negative.
  3. Judge by the descriptor, not by the assignment result. The writable and hasGetSet in getOwnPropertyDescriptor are the reliable readings.
  4. To test assignment, put the probe in a .mjs. Only ESM strict mode really throws the way resolver.ts does.
  5. 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.
  6. An empty loader is enough to trigger it. When troubleshooting, do not rule yourself out just because "I only used --experimental-loader, not tsx".
  7. 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.
  8. Branch on the descriptor when fixing the stack. Use assignment for an accessor, defineProperty for a data property, and preserve configurable.
  9. Preserving semantics beats preserving appearance. A failed rewrite should be swallowed; letting it throw replaces the original error carrying code and misreports "an optional resource is missing" as "the package metadata is corrupted".
  10. Remember there is more than one same-shaped write point. resolver.ts:665/:688, cordis-client-runner/src/client/index.ts:155, and session-persistence-jsonl/src/migration-verifier.ts:168 all write the stack of 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.

DSH Plugin Hub · Plugin market: plugin cards show name, version, category, and description; a metadata read failure is flagged here in red

Source: Discussion #7518, deepseek-ai/deepseek-harness.

FAQ

Why is the same plugin fine on the installed build but flagged red when started from source / `pnpm dsh`?

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.

Is `error.stack` actually read-only? I saw someone who could not reproduce it.

**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.

I tested with `node -e "…e.stack='x'…"` and the assignment succeeded — does that mean stack is writable?

**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.

Does this error only affect built-in plugins? Could my own plugin get hit?

**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".

Have the maintainers fixed it? Should I change code or work around it?

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