Repository navigation
Description
环境 / Environment
- DSH Desktop 0.12.1-test.2(macOS 15 arm64)
- 内置内核
@deepseek-ai/dsh0.2.1-rc.2 dshmarket1.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
- [Bug] DSH Desktop 0.11.0:升级后 profile 迁移必然冻结 —— 闭包目录不存在 + 迁移路径未传
dshEntryPath,宿主单例 peer 校验必失败,插件市场无法安装 #654(open)—— 闭包目录缺失导致的同类报错(本问题独立于它:包在 generation 里,是清单解析出错) - [Bug] verifyGenerationPeers 仍会拒绝「types 字段不带扩展名」的纯声明包(@types/retry@0.12.0 "types":"index")—— #293 / PR #354 的残余缺口 #722 / [Bug] verifyGenerationPeers 仍会拒绝「types 字段不带扩展名」的纯声明包(@types/retry@0.12.0 "types":"index")—— #293 / PR #354 的残余缺口 #721 / 安装插件时 generation peer validation 误判类型声明包(@types/retry),dsh-workbuddy-connect@0.7.3 无法安装 #688(open)——
resolveNonRootPackage()处理纯声明包的其它分支问题。三个问题都在同一个函数里,建议一起看 - [Bug] verifyGenerationPeers 对「宿主自带但包名非 @deepseek-ai/*」的依赖产生误报(@earendil-works/pi-ai 无法解析),导致 generation 插件更新必然失败 #723 / [Bug] verifyGenerationPeers 对「宿主自带但包名非 @deepseek-ai/*」的依赖产生误报(@earendil-works/pi-ai 无法解析),导致 generation 插件更新必然失败 #720(open)—— 非
@deepseek-ai/*宿主包误报 - Plugin market / plugin updates never appear after restart — generation peer validation false-positives freeze the projection #293(closed)—— 同族误报的起源
影响面 / 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
Merge pull request #757 from dataelement/fix/issue-745-resolve-non-ro…
V0.12.2 (#794)
feat: 完善 0.13.0 报错恢复与维修 Agent 流程 (#764)
yaojin3616 commentedon Oct 28, 2026
已核对 DSH Desktop v0.13.0 的发布代码,此问题对应的修复/版本更新已随正式版交付。
包清单探测不再因 exports 通配命中内层 package.json 就提前失败;会继续寻找正确包根并验证有效的运行时子路径。
发布代码:相关实现。 回归覆盖;本次复核的 7 个相关测试文件共 127 项通过、4 项原生平台用例跳过,不将跳过项视为验收通过。
请重新安装或更新 dsh-skill-mcp-panel;若仍失败,请反馈插件版本及新的 generation 校验错误。
按已发布的修复关闭此 issue。请升级至 v0.13.0 后复测;如果同一问题仍出现,欢迎在此回复或重开,并提供 Desktop/Harness/插件版本、操作系统及脱敏日志(不要上传 token、账号凭据或完整会话)。