Skip to content

[Bug] resolveNonRootPackage 被 exports "./*" 通配劫持:@modelcontextprotocol/sdk 的 package.json 被解析成 dist/cjs/package.json(无 name 字段)→ 提前 return,子路径回退逻辑永远跑不到 #745

Closed

Description

@per-hap-s

环境 / Environment

  • DSH Desktop 0.12.1-test.2(macOS 15 arm64)
  • 内置内核 @deepseek-ai/dsh 0.2.1-rc.2
  • dshmarket 1.66.44
  • 组件:resources/app.asar/node_modules/dsh-desktop-market-installer/generations/installer.mjs → resolveNonRootPackage()

现象 / Actual behavior

在插件市场把 dsh-skill-mcp-panel 从 2.1.5 更新到 2.3.5 失败:

generation-install: installed in 4109ms
generation-install: hoisted 2 host singletons: @deepseek-ai/dsh-brand, @deepseek-ai/dsh-typert-protocol…
generation-install: promoted to dsh-skill-mcp-panel+2.3.5+4133977f885c
generation-install: generation peer validation failed: @modelcontextprotocol/sdk does not resolve from the generation or installation closure

关键事实 / Evidence

1. 包物理存在,而且目录完整

$ ls <generation>/node_modules/@modelcontextprotocol/sdk
LICENSE  README.md  dist  node_modules  package.json

$ cat package.json | head -3
{ "name": "@modelcontextprotocol/sdk", "version": "1.32.1" }

701 个文件,dist 6.1 MB。

2. 插件从来没有用裸包名导入过

$ grep -rhoE '@modelcontextprotocol/sdk[a-zA-Z0-9/._-]*' <pkg>/lib | sort -u
@modelcontextprotocol/sdk/client/index.js
@modelcontextprotocol/sdk/client/stdio.js
@modelcontextprotocol/sdk/client/streamableHttp.js

全部是子路径,一次都没有 from '@modelcontextprotocol/sdk'。

3. 子路径解析完全正常

$ node -e 'require.resolve("@modelcontextprotocol/sdk/client")'
OK => <generation>/node_modules/@modelcontextprotocol/sdk/dist/cjs/client/index.js

$ node -e 'require.resolve("@modelcontextprotocol/sdk/server")'
OK => <generation>/node_modules/@modelcontextprotocol/sdk/dist/cjs/server/index.js

$ node -e 'require.resolve("@modelcontextprotocol/sdk")'
FAIL MODULE_NOT_FOUND

4. 上游确实没有发根入口(不是本地损坏)

我核对了 npm 上的原始 tarball:

$ npm view @modelcontextprotocol/sdk@1.32.1
exports["."] = { types: "./dist/esm/index.d.ts", import: "./dist/esm/index.js", require: "./dist/cjs/index.js" }

$ tar -tzf sdk-1.32.1.tgz | grep "dist/esm/index.js"
(无输出)

横向对比相邻版本,从 1.30 起全部缺根入口:

版本 dist/esm/index.js dist/cjs/index.js
1.30.1 不存在 不存在
1.31.0 不存在 不存在
1.32.0 不存在 不存在
1.32.1 不存在 不存在

dist 目录下只有 client/、server/、experimental/、validation/ 这些子目录 + 若干顶层 .js,没有 index.js。这是上游的发包缺陷,有独立的 issue 可查;但对 DSH 来说,它意味着「有 exports["."] 但目标文件不存在」的包会真实存在。

根因 / Root cause

installer.mjs 的 resolveNonRootPackage() 对这一类包本来就写了正确的回退逻辑,注释写得很清楚:

608:    // Some SDKs publish a root export without shipping its target, while their
609:    // supported API is exposed through concrete client/server subpaths. Package
610:    // dependency validation must not require an unused root entry. Require an
611:    // existing runtime subpath instead; a manifest alone is insufficient here.
612:    if (exported !== null && typeof exported === 'object' && !Array.isArray(exported)) {
613:      for (const [subpath, target] of Object.entries(exported)) {
...
618:        if (existsSync(entry)) return entry
619:      }
620:    }

但这段代码一行都没跑到,因为它在 :575 提前 return undefined 了:

556:  async function resolveNonRootPackage(requireFromPackage, dependency) {
557:    // Harness intercepts package resolution, but resolve.paths() still exposes
558:    // legacy filesystem links. Ask the active resolver for the manifest first.
559:    let selectedManifest
560:    try {
561:      selectedManifest = requireFromPackage.resolve(`${dependency}/package.json`)   // ← 就是这一行
562:    } catch {
...
575:      if (manifest.name !== dependency) return undefined    // ← 直接放弃

为什么 :561 会解析到错的文件

该 SDK 的 exports 里有一条通配:

"./*": { "types": "./dist/esm/*.d.ts", "import": "./dist/esm/*", "require": "./dist/cjs/*" }

于是 resolve("@modelcontextprotocol/sdk/package.json") 被 ./* 吃掉,星号替换成 package.json,命中 require 条件 → dist/cjs/package.json。

而那个文件只有一行:

{"type": "commonjs"}

没有 name 字段。

于是 :575:

manifest.name !== dependency   // undefined !== '@modelcontextprotocol/sdk' → true
→ return undefined

整个函数放弃,verifyGenerationPeers() 在 :724-730 报「does not resolve」。

实测复现

1) 安装器解析 dep/package.json => <generation>/node_modules/@modelcontextprotocol/sdk/dist/cjs/package.json
   该文件内容: {"type": "commonjs"}
   有 name 字段? false

2) 真实清单: <generation>/node_modules/@modelcontextprotocol/sdk/package.json
   name: @modelcontextprotocol/sdk  version: 1.32.1

3) 若 bypass 掉 ./* 劫持,安装器的后续逻辑会怎么走:
   根导出目标: ./dist/esm/index.js => 存在? false
   子路径回退扫描:
   ✔ ./client => ./dist/esm/client/index.js
   结论:若不被 ./* 劫持,回退会返回 => .../dist/esm/client/index.js

只要 :575 那一步不误判,问题就消失了 —— 也就是「差一行」。

建议修复 / Suggested fix

方案 1(推荐):package.json 解析失败时直接读文件系统

exports 的 ./* 通配本来就不该拦截包的自身清单 —— Node 官方语义里 package.json 也是特例之一,很多工具靠直接读盘绕开。

let selectedManifest
try {
  selectedManifest = requireFromPackage.resolve(`${dependency}/package.json`)
} catch {
  // 保持原有 fallback
}
// 新增:通配导出可能把 package.json 映射到内层文件,回退到真实路径
if (selectedManifest !== undefined) {
  const direct = join(packageDirectoryOf(requireFromPackage, dependency), 'package.json')
  ...
}

更简洁的写法:先用 resolve.paths() 拿候选目录,校验 manifest.name 后再采用,不要因为第一个候选名字不对就整体放弃:

const candidates = selectedManifest ? [selectedManifest] : []
// 始终追加真实路径候选,即使 resolve 成功了
for (const modules of requireFromPackage.resolve.paths(dependency) ?? []) {
  candidates.push(join(modules, dependency, 'package.json'))
}

for (const file of candidates) {
  const manifest = await readJsonOrUndefined(file)
  if (manifest?.name !== dependency) continue   // ← 跳过无效候选,而不是 return
  ...
}

关键点:把 :575 的 return undefined 改成 continue。 现在第一个候选不对就彻底放弃,实际上还有正确候选排在后面。

方案 2:识别"内层 type 声明文件"并跳过

if (manifest.name !== dependency) {
  // {"type": "commonjs"} 这类 exports 通配命中的内层声明不是包清单
  if (Object.keys(manifest).every(k => k === 'type')) continue
  return undefined
}

补充建议:错误文案应区分两种情况

当前 :728 无论「包真的不存在」还是「清单被通配劫持导致误判」都报同一句话,用户无从判断。建议附上实际解析到的路径:

problems.push(`${prefix}${dependency} does not resolve from the generation or installation closure` +
  (selectedManifest ? ` (manifest probe landed on ${selectedManifest})` : ''))

关联 / Related

影响面 / Impact

任何依赖 @modelcontextprotocol/sdk(1.30+)且只使用子路径导入的插件,在这个桌面版本上一律无法安装/更新。这类插件在 MCP 生态里不在少数 —— 该 SDK 官方文档本身就推荐 /client、/server 子路径用法。

附:可复现脚本 / Repro

const { createRequire } = require('node:module')
const { readFileSync, existsSync } = require('node:fs')
const { join } = require('node:path')

const genRoot = '<generation>/node_modules'
const rq = createRequire(join(genRoot, 'dsh-skill-mcp-panel/package.json'))

// 安装器实际拿到的"清单"
const hijacked = rq.resolve('@modelcontextprotocol/sdk/package.json')
console.log(hijacked)                                  // => .../dist/cjs/package.json
console.log(readFileSync(hijacked, 'utf8'))            // => {"type":"commonjs"}
console.log(JSON.parse(readFileSync(hijacked,'utf8')).name)  // => undefined

// 真实清单
const real = JSON.parse(readFileSync(join(genRoot, '@modelcontextprotocol/sdk/package.json'), 'utf8'))
console.log(real.name, real.version)                   // => @modelcontextprotocol/sdk 1.32.1

Activity

  1. added 3 commits that reference this issue on Oct 26, 2026
  2. yaojin3616 commented on Oct 28, 2026

    @yaojin3616
    Contributor

    已核对 DSH Desktop v0.13.0 的发布代码,此问题对应的修复/版本更新已随正式版交付。

    包清单探测不再因 exports 通配命中内层 package.json 就提前失败;会继续寻找正确包根并验证有效的运行时子路径。

    发布代码:相关实现。 回归覆盖;本次复核的 7 个相关测试文件共 127 项通过、4 项原生平台用例跳过,不将跳过项视为验收通过。

    请重新安装或更新 dsh-skill-mcp-panel;若仍失败,请反馈插件版本及新的 generation 校验错误。

    按已发布的修复关闭此 issue。请升级至 v0.13.0 后复测;如果同一问题仍出现,欢迎在此回复或重开,并提供 Desktop/Harness/插件版本、操作系统及脱敏日志(不要上传 token、账号凭据或完整会话)。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions