DeepSeek Harness prepare:dsh 失败:payload smoke 断言了已移除的 fs-ext

故障排查发布于 2026-10-03作者: DeepSeek Plugin 插件市场
DeepSeek HarnessDSH桌面端打包prepare:dshpayload smokefs-extnode-addon-systemelectron-builder
在 0.1.5-rc.2 上跑 prepare:dsh,smoke 子进程必抛 Cannot find module 'fs-ext',打包链中止、resources/dsh 生不出来。本文讲清「断言过期」机制、它为何能潜伏(汇总行无人消费)、koffi 为何不能删,以及把汇总行升级成契约的三层修法。

在 0.1.5-rc.2 上从源码构建桌面端,prepare:dsh 会在 bundled-runtime payload smoke 处必然中止:Cannot find module 'fs-ext',随后 electron-builder 以 ENOENT …resources\dsh\desktop-runtime.json 收场——而 fs-ext 早已不是任何包的依赖,所以这个断言在任何平台、任何机器上都不可能通过(#6372)。 根因是 fixture 里那个 checkFsExt() 成了「永远不会成功的检查」,而它之所以能一直躺在这里、连报三次跑都只是同一个错,是因为它打印的汇总行没有任何消费者——删掉任何一个 check*,与「它通过了」在一次绿色运行里完全无法区分。本文按「先分诊 → 两条机制(断言过期 + 汇总行无人读)→ 三种修法(删 / 升级为契约 / 补覆盖)→ 连带清理」展开,每一步都给可粘贴的补丁与实测结果;其中有一条最容易踩的坑写在标题里:别顺手把 koffi 一起删掉。

先分诊:这是「断言过期」,不是「依赖漏装」

遇到 Cannot find module 'fs-ext',第一反应往往是「少装了个包」,但这条错误恰恰是反过来的:是检查在找一个已经被删掉的包。 两者处理方式完全相反,先用三行命令把范围钉死。

bash
# 1. 全树有没有任何一个 manifest 还声明 fs-ext?
git grep -n '"fs-ext"' -- '*package.json' || echo 'NO manifest declares fs-ext'

# 2. lockfile 里有没有它的条目?
grep -n 'fs-ext' pnpm-lock.yaml || echo 'NO lockfile entry'

# 3. 还有哪些文件在提这个名字?
git grep -n 'fs-ext' -- ':!pnpm-lock.yaml'

实测输出是第一个命令零匹配、第二个命令只匹配到 fs-extra、第三个命令只命中四处残留(见下表)。这就把可能性排除到只剩一条。

判据「断言过期」(本例)「依赖漏装」(常见误判)
package.json 是否声明全树零声明有声明但没装上
pnpm-lock.yaml 是否有条目无有,或缺失导致 frozen 失败
装回去能否解决不能,--prod --frozen-lockfile 照样装不出能
全新 temp project + 全新 store 是否复现必然复现(本机连续三次)视缓存而定
真正的修法删掉这个断言补依赖 / 修 lockfile

报告人在 Windows 10 x64、Node v24.17.0 上,用全新 temp project + 全新 pnpm store 连续跑了三次 prepare:runtime → prepare:packages → prepare:dsh,三次都在同一行断掉(#6372)。「换环境就好」这条路先被堵死了。

机制一:checkFsExt() 在任何平台都不可能成功

一句话:fixture 在 payload 内用 pnpm install --prod --frozen-lockfile 装依赖,而没有任何 manifest 或 lockfile 条目会产出 fs-ext,所以 requireRuntime('fs-ext') 一定抛,checkFsExt() 一定失败,prepare-dsh.ts 又把它当硬失败。

崩塌链的四步

checkFsExt()                                  apps/desktop/tests/fixtures/runtime-payload-smoke.mjs:69
  └─ requireRuntime('fs-ext')                 在 payload 内解析,必然 Cannot find module
       └─ 异常冒泡到 fixture 顶层 try          :124
            └─ 子进程非零退出
                 └─ prepare-dsh.ts 回调 throw   terminate 整个脚本
                      └─ resources/dsh 不生成
                           └─ electron-builder ENOENT …\resources\dsh\desktop-runtime.json
                                (verifyDesktopRuntime / afterPack 收尾失败)

关键点在第二行:requireRuntime 的解析根是安装出来的 payload,不是源树。而 payload 的依赖集合由 pnpm install --prod --frozen-lockfile 决定——一个既不在任何 package.json、又不在 pnpm-lock.yaml 里的名字,永远不可能出现在结果里。所以这不是「某台机器缺包」,而是一条结构性不可满足的断言。

它被谁替换了

fs-ext 的移除不是丢失,而是一次有记录的迁移。会话写入租约(session.lock 那一层)现在走预构建的 Node-API system addon:

ts
// packages/session/session-persistence-jsonl/src/lease.ts:34
import { tryLockExclusive } from '@deepseek-ai/node-addon-system/flock'

这次迁移写在 .agents/notes/implemented/architecture/2026-09-07-prebuilt-system-primitives.md:用预构建的 Node-API system addon 取代安装期编译的 NAN fs-ext。迁移完成后,全树唯一还在引用 fs-ext / seekSync 的代码,就是这个 fixture 自己。

残留清单(不影响构建,但语义已是死代码)

位置内容性质
apps/desktop/tests/fixtures/runtime-payload-smoke.mjs:69/124/137checkFsExt() 及其调用与汇总键就是本次故障本体
apps/desktop/scripts/runtime-file-policy.ts:26/30fs-ext/build/** 排除规则被 spec 覆盖,改要连 spec 一起改
apps/desktop/src/project-manager.ts:109allowBuilds: fs-ext: true指向不可能安装的包
vitest.config.ts:98一句陈旧注释建议随清理一起带走

注意第三列的分类——这是很多人会看错的地方。runtime-file-policy.ts 的两条规则不是惰性残留:apps/desktop/tests/runtime-file-policy.spec.ts 用 7 个向量在测它们(含第 73 行的嵌套路径正例,以及 20–24、38 行的保留向量)。单独删规则会让 spec 变红,所以它们必须与 spec 作为同一批改动处理,而不能和本次 smoke 修复混在一个 PR 里——这是报告人与评审者都同意的排序。

机制二:为什么它能潜伏这么久——汇总行是一份没人读的报告

一句话:fixture 算出一行汇总 {node, platform, arch, fsExt, koffi, sharp, html, pty} 并打印到 stdout,prepare-dsh.ts:145 原样透传、从不解析,全树没有任何消费者——于是「某个 check 被删掉」和「某个 check 通过了」在绿色运行里长得一模一样。

这条比第一条更值得单独讲,因为它解释了为什么一个从未成功过的断言能活到发版。

ts
// apps/desktop/scripts/prepare-dsh.ts 附近(修复前)
else { process.stdout.write(stdout); accept() }   // stdout 直写,不解析那行 JSON

评审者用 git grep 确认过:那些键(fsExt、koffi……)在整棵树里没有任何读取方。fsExt 键因此得以长期报 true,而没有任何地方会对此提出异议。把它说得再直白一点:

在修复前,从 fixture 里删掉任意一个 check* 调用,绿色运行的表现与「它真的通过了」完全无法区分。

失败路径是好的,缺的是成功路径

需要公平地说清楚:失败路径没问题。

  1. prepare-dsh.ts:144 对任何非零退出都会 reject,并把子进程 stderr 包进错误信息——补丁打坏了一定会响。
  2. DESKTOP_HOST_RUNTIME_FILES 会做存在性检查并 throw,少文件也不会静默。

真正没被验证的是成功:一次绿灯的 prepare:dsh 目前只断言了「子进程退出码为 0」,而不是「这六项检查真的跑了」。把汇总行接过来解析只花一行代码,却能把「下一次删掉某个 check」从静默变成可见的失败。这正是本次修复应当顺带完成的部分——它才是当初能拦住这个 bug 的属性。

别删错:koffi 必须保留,理由写在迁移说明的反面

一句话:那份迁移说明读起来像一张移除清单,但它恰恰保留了 koffi——被拒的替代方案才是「用 koffi 做 POSIX 调用」,而 Windows 锁到今天仍跑在既有的 koffi semaphore 上。

这是本文最想强调的一条。原始报告里有一句「其余 koffi / sharp / turndown+gfm / node-pty 断言针对的模块都在 package set 中真实存在,保持不变」——评审者特意回头核对并明确背书了它,原因有两个:

  1. 迁移说明的 rejected-alternatives 表把「用 koffi 做 POSIX 调用」列为 fs-ext 的被拒替代。也就是说,被采纳的方向是预构建 Node-API addon,而「用 koffi 顶替 POSIX 那一半」是当时被否掉的方案。把 checkKoffi() 当成旧方案删掉,正好是把「被拒」读成了「被采纳」。
  2. Windows 锁仍由 koffi semaphore 承担,这是说明里一条被采纳的结论。删掉它,就等于删掉了 Windows 上真实存在的那条保障。

补一条硬数据:koffi 在树内有 6 个 manifest 声明它——fs-local、directory-picker-native、sandbox-windows-acl、session-persistence-jsonl、subprocess-local、win32-process。所以「删掉 koffi」不是清理死代码,而是用一个新错误换掉一个已经修好的错误。

DSH plugin 的三种修法:删断言、升级为契约、补覆盖

修法一:最小删除(原始报告的单文件补丁,+2/−19)

如果你的目标只是「让 0.1.5-rc.2 能出包」,这一步就够:把已经不可能成立的 checkFsExt() 连同它的调用、汇总键与不再使用的 node:fs 导入一起删掉。

diff
--- a/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs
+++ b/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs
@@ -1,7 +1,7 @@
 /** Exercise filtered Desktop native and HTML dependencies under its bundled Node. */
 
 import assert from 'node:assert/strict'
-import { closeSync, mkdtempSync, openSync, readFileSync, readSync, writeFileSync } from 'node:fs'
+import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
 import { rm } from 'node:fs/promises'
 import { createRequire } from 'node:module'
 import { tmpdir } from 'node:os'
@@ -64,22 +64,6 @@ async function checkPty() {
   }
 }
 
-/** fs-ext implements seek on Windows through SetFilePointerEx and on POSIX through lseek. */
-function checkFsExt() {
-  const fsExt = requireRuntime('fs-ext')
-  const file = join(scratch, 'seek.txt')
-  writeFileSync(file, 'abcdef', { flag: 'wx', mode: 0o600 })
-  const fd = openSync(file, 'r')
-  try {
-    assert.equal(fsExt.seekSync(fd, 2, fsExt.constants.SEEK_SET), 2)
-    const bytes = Buffer.alloc(4)
-    assert.equal(readSync(fd, bytes, 0, bytes.length, null), 4)
-    assert.equal(bytes.toString(), 'cdef')
-  } finally {
-    closeSync(fd)
-  }
-}
-
 /** Resolve one system function through Koffi's packaged native module. */
 function checkKoffi() {
   const koffi = requireRuntime('koffi')
@@ -121,7 +105,6 @@ function checkHtml() {
 }
 
 try {
-  checkFsExt()
   checkKoffi()
   await checkSharp()
   checkHtml()
@@ -134,5 +117,5 @@ try {
 process.once('beforeExit', () => {
   console.log(JSON.stringify({ node: process.versions.node, platform: process.platform, arch: process.arch,
-    fsExt: true, koffi: true, sharp: true, html: true, pty: true }))
+    koffi: true, sharp: true, html: true, pty: true }))
 })

做与不做: 只做这一步,prepare:dsh 就能过;但它没有修好机制二(汇总行仍无人读)。所以下面两步被报告人与评审者视为「同一批修复的一部分」,而不是后续工作——因为机制二才是当初让这个 bug 存活下来的属性。

修法二:把「打印一行」升级成契约(推荐与修法一同批做)

一句话:给汇总行加一个前缀,让 prepare-dsh.ts 解析它、并做集合相等双向校验;同时把解析错误刻意转成 reject,否则 execFile 回调里抛异常会让 promise 永远 pending、把 prepare 直接挂死。

fixture 侧:加前缀 + 换掉 check + 改汇总形状

diff
--- a/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs
+++ b/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs
@@ -17,6 +17,9 @@ assert.equal(process.arch, descriptor.arch)
 const requireRuntime = createRequire(join(root, 'package.json'))
 const scratch = mkdtempSync(join(tmpdir(), 'dsh-runtime-payload-'))
 
+/** Prefix of the summary line prepare-dsh.ts parses; keep both copies in sync. */
+const SUMMARY_PREFIX = 'desktop-runtime-payload-smoke '
+
 /** Spawn only a fixed Node program and await the terminal's drained exit event. */
 async function checkPty() {
   const pty = requireRuntime('node-pty')
@@ -64,17 +67,19 @@ async function checkPty() {
   }
 }
 
-/** fs-ext implements seek on Windows through SetFilePointerEx and on POSIX through lseek. */
-function checkFsExt() {
-  const fsExt = requireRuntime('fs-ext')
-  const file = join(scratch, 'seek.txt')
-  writeFileSync(file, 'abcdef', { flag: 'wx', mode: 0o600 })
+/**
+ * The prebuilt Node-API system addon replaced fs-ext on POSIX. Windows keeps its
+ * koffi semaphore, so only the `./flock` subpath has to resolve there.
+ */
+async function checkSystemFlock() {
+  const flock = requireRuntime('@deepseek-ai/node-addon-system/flock')
+  assert.equal(typeof flock.tryLockExclusive, 'function')
+  if (process.platform === 'win32') return
+  const file = join(scratch, 'flock.txt')
+  writeFileSync(file, '', { flag: 'wx', mode: 0o600 })
   const fd = openSync(file, 'r')
   try {
-    assert.equal(fsExt.seekSync(fd, 2, fsExt.constants.SEEK_SET), 2)
-    const bytes = Buffer.alloc(4)
-    assert.equal(readSync(fd, bytes, 0, bytes.length, null), 4)
-    assert.equal(bytes.toString(), 'cdef')
+    await flock.tryLockExclusive(fd)
   } finally {
     closeSync(fd)
   }
@@ -121,7 +126,7 @@ function checkHtml() {
 }
 
 try {
-  checkFsExt()
+  await checkSystemFlock()
   checkKoffi()
   await checkSharp()
   checkHtml()
@@ -132,7 +137,9 @@ try {
 }
 
 // Natural event-loop drain includes node-pty's worker and console-list helper teardown.
+// prepare-dsh.ts asserts this summary, so a dropped check cannot pass as a green run.
 process.once('beforeExit', () => {
-  console.log(JSON.stringify({ node: process.versions.node, platform: process.platform, arch: process.arch,
-    fsExt: true, koffi: true, sharp: true, html: true, pty: true }))
+  console.log(`${SUMMARY_PREFIX}${JSON.stringify({ runtime: { node: process.versions.node,
+    platform: process.platform, arch: process.arch },
+    checks: { systemFlock: true, koffi: true, sharp: true, html: true, pty: true } })}`)
 })

注意这里做了两件事:删掉 fs-ext 断言(修法一),同时把真正缺失的那块覆盖补回来(修法三,见下一节)。汇总行的形状从「平铺的键」改成 {runtime, checks} 的嵌套结构,只为让「哪几个是检查项」这件事在代码里显式可枚举。

脚本侧:解析 + 集合相等 + 解析错误转 reject

diff
--- a/apps/desktop/scripts/prepare-dsh.ts
+++ b/apps/desktop/scripts/prepare-dsh.ts
@@ -38,6 +38,11 @@ const PACKAGE_SET_ROOT = BUILD_PATHS.packageSet
 const NODE = join(RUNTIME_ROOT, 'node', process.platform === 'win32' ? 'node.exe' : 'node')
 const PNPM = join(RUNTIME_ROOT, 'pnpm', 'bin', 'pnpm.mjs')
 
+/** Must match the summary prefix in tests/fixtures/runtime-payload-smoke.mjs. */
+const PAYLOAD_SMOKE_SUMMARY_PREFIX = 'desktop-runtime-payload-smoke '
+/** Native checks the bundled payload must report; a silent removal must not look like a pass. */
+const EXPECTED_PAYLOAD_SMOKE_CHECKS: readonly string[] = ['systemFlock', 'koffi', 'sharp', 'html', 'pty']
+
 function manifestVersion(path: string, subject: string): string {
   const manifest = JSON.parse(readFileSync(path, 'utf8')) as { version?: unknown }
   if (typeof manifest.version !== 'string') throw new Error(`desktop runtime: ${subject} has no version`)
@@ -100,6 +105,17 @@ function runPnpm(args: readonly string[]): Promise<void> {
   })
 }
 
+function verifyPayloadSmokeSummary(stdout: string): void {
+  const line = stdout.split(/\r?\n/u).filter(entry => entry.startsWith(PAYLOAD_SMOKE_SUMMARY_PREFIX)).at(-1)
+  if (line === undefined) throw new Error('desktop runtime: payload smoke printed no summary line')
+  const summary = JSON.parse(line.slice(PAYLOAD_SMOKE_SUMMARY_PREFIX.length)) as { checks?: Record<string, unknown> }
+  const checks = Object.entries(summary.checks ?? {})
+  if (checks.length !== EXPECTED_PAYLOAD_SMOKE_CHECKS.length
+    || checks.some(([name, value]) => value !== true || !EXPECTED_PAYLOAD_SMOKE_CHECKS.includes(name))) {
+    throw new Error(`desktop runtime: payload smoke reported ${JSON.stringify(summary.checks ?? null)}`)
+  }
+}
+
 async function main(): Promise<void> {
   rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true })
   rmSync(PNPM_BUILD_STATE, { recursive: true, force: true })
@@ -142,7 +158,15 @@ async function main(): Promise<void> {
       execFile(NODE, [join(APP_ROOT, 'tests/fixtures/runtime-payload-smoke.mjs'), DSH_OUTPUT_ROOT],
         { timeout: 120_000, env: { ...process.env, NODE_OPTIONS: '' } }, (error, stdout, stderr) => {
           if (error !== null) reject(new Error(`desktop native payload smoke failed: ${stderr}`, { cause: error }))
-          else { process.stdout.write(stdout); accept() }
+          else {
+            try {
+              verifyPayloadSmokeSummary(stdout)
+              process.stdout.write(stdout)
+              accept()
+            } catch (cause) {
+              reject(cause instanceof Error ? cause : new Error(String(cause)))
+            }
+          }
         })
     })
     await smokeDesktopRuntime(DSH_OUTPUT_ROOT, NODE, descriptor)

两个刻意的设计决定

这两处是报告人明说「预计会被质疑」的地方,值得逐条解释:

  1. 用了集合相等,而不是「期望键都存在」。 校验条件同时检查 checks.length === EXPECTED.length 和 每个上报项 value === true 且名字在期望表里。为什么不是单向?因为只查「期望的都在」的话,fixture 里新增了一个 check 却忘了更新期望表这种方向是漏的。集合相等把两个方向都关上。
  2. 解析错误被故意转成 reject。 如果直接在 execFile 的回调里 throw,那个异常会逃出回调,promise 既不会 accept 也不会 reject,于是 prepare 永久 pending、直接挂住。显式 reject 才能让它「响亮地失败」而不是「安静地卡死」。这是这段代码里最容易写错的一行。

修法三:补上真正缺失的那块 payload 覆盖(checkSystemFlock)

一句话:这次迁移新增的 @deepseek-ai/node-addon-system 在 payload 侧零覆盖,而它恰恰是这只 smoke 存在的意义所在——补一个 checkSystemFlock(),POSIX 上真锁一次,Windows 上只断言子路径能解析。

评审者把这条列为「关于 payload 而非 fixture」的第二点,逻辑很干净:

  • 租约导入的是 @deepseek-ai/node-addon-system/flock(lease.ts:34,已验证),该家族源码树自带 test/flock.test.js——这是源树的信任模型,也就是迁移说明里说「此前已被覆盖」的那种模式。
  • payload 侧没有任何对等物:fixture 原来只断言 pty、koffi、sharp、html,没有任何一行去 require node-addon-system 或解析它的平台包。

而这只 fixture 守护的失效模式正是「install 产出的原生布局与源树不同」,平台包契约又是其中最年轻、最少被走到的部分。为什么不能靠「打包」本身兜住?因为没有任何预构建二进制能跨 OS、CPU、libc 与 Node ABI 通用——这正是该家族被拆成按平台分包的原因。而缺平台包的失效会在裸 require 该子路径时立刻暴露,所以 payload 检查成本极低、不需要任何新的 fixture 机制。

跨平台语义必须写清楚

checkSystemFlock() 的行为在两个平台上是不对称的,这一点如果不注释清楚,很容易被后人误读成「Windows 也验过平台包」:

ts
async function checkSystemFlock() {
  const flock = requireRuntime('@deepseek-ai/node-addon-system/flock')
  assert.equal(typeof flock.tryLockExclusive, 'function')
  if (process.platform === 'win32') return          // ← Windows 到此为止
  const file = join(scratch, 'flock.txt')
  writeFileSync(file, '', { flag: 'wx', mode: 0o600 })
  const fd = openSync(file, 'r')
  try {
    await flock.tryLockExclusive(fd)                // ← 只有 POSIX 真锁
  } finally {
    closeSync(fd)
  }
}

原因在包的声明里:native/system/packages/entry/package.json 只为 darwin-* 与 linux-* 声明 optionalDependencies,没有 win32-*;flock.js 按设计是懒加载的;在 Windows 上调用 tryLockExclusive 会以 ERR_FLOCK_UNSUPPORTED_PLATFORM 拒绝。所以:

  • Linux / macOS:解析平台包 + 真正 tryLockExclusive(fd),这就是 payload 一直缺的那块覆盖。
  • Windows:只断言 ./flock 子路径能在 payload 内解析;真正的 Windows 锁是 koffi semaphore,已经由 checkKoffi() 覆盖;system.node 的字节只在 POSIX 车道上被走到。

DSH plugin 收尾:连带清理、端到端实测与排查注意事项

连带清理:policy 规则与 allowBuilds 必须与 spec 同批改,不进这个 PR

一句话:runtime-file-policy.ts 的 fs-ext 规则被 7 个测试向量压着,allowBuilds 里那条 fs-ext: true 也是同一批死声明——它们应当作为一个独立改动一起走,而不是塞进 payload smoke 的修复里。

原始报告把这两处称为「惰性残留」,评审者明确指出这个说法不准确,并给了向量位置:

位置内容处理要求
runtime-file-policy.spec.ts:20–24、:38fs-ext 规则的保留向量必须与规则同一批改
runtime-file-policy.spec.ts:73嵌套路径正例同上,删规则即变红
runtime-file-policy.ts:26/30fs-ext/build/** 排除规则与 spec 同批
project-manager.ts:109allowBuilds: fs-ext: true建议随 policy 清理
vitest.config.ts:98陈旧注释不反对一起带走

allowBuilds 为什么也要一起动?因为它本来就是一份刻意收窄的声明集,逐项列出每一个有意为之的安装期编译(node-pty、koffi、fs-ext)。一条指向「不可能被安装的包」的条目虽然逻辑自洽,但已经不再有意义——而这类过期条目正是让本 bug 变得昂贵的根源。

端到端实测:Windows 10 x64 上的完整链路

一句话:打上两文件补丁后,prepare:packages 与 prepare:dsh 均退出 0,新的校验消费到了汇总行,后续 smokeDesktopRuntime 与 verifyDesktopRuntime 也通过,resources/dsh 产出 114.1 MB / 188 个顶层包。

报告人最终把补丁跑通了,并给出了 prepare-dsh.ts 那次新校验实际读到的汇总行:

desktop-runtime-payload-smoke {"runtime":{"node":"24.17.0","platform":"win32","arch":"x64"},"checks":{"systemFlock":true,"koffi":true,"sharp":true,"html":true,"pty":true}}

同一进程里跟在 fixture 之后的关卡也都过了:smokeDesktopRuntime(拉起 Host、挂一个外部插件、抓取 dsh-app://app/)与最终的 verifyDesktopRuntime——说明在这台目标机上,记录的清单与实际落盘的树是对得上的。

三条附注(同样值得记住):

  1. Windows 确实能解析 payload 内的 ./flock 子路径,这正是新检查在 Windows 上断言的全部内容;system.node 的字节仍只在 POSIX 车道被走到,因为 entry 包没有 win32-* optionalDependency。将来若想收紧检查,必须先解决这个前提。
  2. prepare:runtime 在这台机器上以 13 退出,stderr 是 Detected unsettled top-level await(prepare-runtime.ts 里),归档本身校验通过。这是既有本地怪癖,与补丁无关:它已经落盘的 runtime 树是完整的(node v24.17.0、pnpm 11.7.0),因此直接从 prepare:packages + prepare:dsh 续跑即可。
  3. 下游打包路径也验证过:electron-builder 复制 resources/dsh/node_modules(node-pty 的 ConPTY helper、ripgrep),afterPack 的 verifyDesktopRuntime 在成包树上通过,抽取副本的 smoke 从 profile 建出 241 个 junction、窗口约十秒起来。报告人说明这部分是他本地的未签名路线、不属于上游改动——面向上游的完整增量只有 fixture 与 prepare-dsh.ts 两个文件。

排查注意事项

  1. 看到 Cannot find module 'X' 先问一句「X 现在还是依赖吗」。 本例里第一反应(装回去)是反向的;git grep -- '*package.json' 与 pnpm-lock.yaml 两个命令就能定性。
  2. 别被「换台机器试试」带偏。 报告人用全新 temp project + 全新 store 连跑三次、三次同点失败——结构性问题不会因为环境干净而消失。
  3. 分清「断言不可能成功」与「断言可能失败但没跑」。 前者要删,后者要修 fixture 逻辑,处理方式不同。
  4. 迁移说明读成清单时,务必确认每一条的方向**。** fs-ext 是被移除的,koffi 是被保留的;把「被拒替代」读成「被采纳」,就会删错东西。
  5. 汇总行没人读 = 检查可以静默消失。 只要一个 check 的结果不进入任何判定,它就是在裸奔;把结果接进判定,比多写一个 check 更重要。
  6. 校验用集合相等,不要用「期望键都存在」。 单向校验只堵一个方向,新增 check 忘登记这种错误正好从另一个方向漏过去。
  7. execFile 回调里绝不要裸 throw。 必须 reject,否则 promise 永久 pending、把整个 prepare 挂死,比失败更难查。
  8. 改与被测数据相关的规则时,先找它的 spec。 runtime-file-policy.ts 的 fs-ext 规则单独删会让 runtime-file-policy.spec.ts 变红,必须同批。
  9. allowBuilds 这类白名单也会腐坏。 一条指向无法安装的包的条目看着无害,实际是下一个人的陷阱。
  10. 平台包检查要注释清语义。 Windows 上「只解析子路径」和 POSIX 上「真锁一次」不是等价覆盖,写清楚才不会让后人把 Windows 绿灯当成平台包验证过了。

这只 smoke 真正的价值不是「多验一个包」,而是把「安装产出的原生布局」变成一份可读、可判定的契约。 如果你的二次分发里也内置 DSH 运行环境,或者你在 CI 里跑 prepare:dsh,建议顺手做两件事:把汇总行接进判定,让「下次删掉某个 check」变成一次显式失败;以及在下一次原生依赖迁移时,先确认每个名字的方向——被移除的删、被保留的留,尤其别把 koffi 这种仍在承载 Windows 锁的依赖当成旧包袱清掉。用 DSH Plugin Hub 的话,插件装卸、更新确认与系统日志都在一个面板里,排查这类环境问题能少绕几步。

DSH Plugin Hub · 确认更新

来源:Discussion #6372。

常见问题

我只是想装个插件,为什么会撞上 prepare:dsh 这条命令?

prepare:dsh 是桌面端在本地构建时用来生成 resources/dsh(打包进安装包的那份 Host 运行环境)的脚本,属于「从源码构建桌面端 / 打本地包」链路,普通用户从安装包装 DSH 不会走到它。你会在两种情况下遇到:一是你自己从 monorepo 出包,二是你维护的是内置了 DSH 运行环境的二次分发。普通插件安装遇到这类问题,看的是日志里的另一批错误码。

报错说 `Cannot find module 'fs-ext'`,我把 fs-ext 装回去行不行?

不建议,也基本不成立。全树没有任何 package.json 声明 fs-ext、pnpm-lock.yaml 里也没有条目,而 fixture 是在 payload 内用 pnpm install --prod --frozen-lockfile 装出来的——你手工补一个包,--frozen-lockfile 那一关照样过不去,下次全新 temp project + 全新 store 又会失败。真正的修法是删掉这个已经不可能成立的断言,而不是把被移除的依赖请回来。

同一份迁移还引入了 `@deepseek-ai/node-addon-system`,那为什么 smoke 里原本没有它的检查?

因为它走的是另一条信任链。迁移说明里,源树一侧由该家族自带的 test/flock.test.js 覆盖;而这个 fixture 守护的是「安装产出的原生布局与源树不一致」这一失效模式,也就是说 payload 侧才是它该管的地方,原先却一条都没有。修法三补的 checkSystemFlock() 正是这条缺口。

修的时候为什么有人一直提醒「koffi 不能删」?

因为那份迁移说明读起来像一张「移除清单」,很容易让人把它当成「被淘汰的旧方案」一并清掉。事实相反:说明的 rejected-alternatives 表把「用 koffi 做 POSIX 调用」列为 fs-ext 的**被拒替代**,即被采纳的方向是预构建 Node-API addon;同时说明里还有一节明确让 Windows 锁继续跑在既有的 koffi semaphore 上。koffi 在树内有 6 个 manifest 声明它,删掉就是用一个新的错误换掉一个已经修好的错误。

我打了补丁,但 `prepare:runtime` 退出码是 13,是补丁又出问题了吗?

多半不是。报告人在 Windows 上实测时 prepare:runtime 会以 13 退出,stderr 是 Detected unsettled top-level await,属于既有本地怪癖,与 payload smoke 无关——它已经落盘的 runtime 树是完整的(node v24.17.0、pnpm 11.7.0),可以直接从 prepare:packages + prepare:dsh 继续。判断依据是那两个脚本是否退出 0、以及最终 resources/dsh 是否生成。

相关术语

payload smoke
指 `apps/desktop/tests/fixtures/runtime-payload-smoke.mjs`:在打包进安装包的**已安装 payload** 里,用桌面端自带的那份 Node,逐个 `require` 原生与 HTML 依赖并做最小功能调用,用来证明「安装产出的原生布局」是完整可用的。它守护的失效模式是「install 出来的东西和源树不一样」,而不是源树里有没有这个包。— https://github.com/deepseek-ai/deepseek-harness/discussions/6372
fs-ext
一个安装期编译的 NAN addon,历史上用于提供 POSIX `lseek` / Windows `SetFilePointerEx` 之类的 seek 能力(本 fixture 曾用它断言 `seekSync`)。已在一次架构迁移中被移除:租约改走预构建的 Node-API system addon,全树不再有任何 manifest 或 lockfile 条目声明它。— https://github.com/deepseek-ai/deepseek-harness/discussions/6372
@deepseek-ai/node-addon-system/flock
取代 fs-ext 的预构建 Node-API system addon 家族中的 flock 子路径。因为预构建二进制无法跨 OS / CPU / libc / Node ABI 通用,该家族被拆成按平台分发的包;`entry` 包只为 `darwin-*` 与 `linux-*` 声明 optionalDependencies,`flock.js` 懒加载,因此在 Windows 上调用 `tryLockExclusive` 会抛 `ERR_FLOCK_UNSUPPORTED_PLATFORM`,Windows 写锁仍由 koffi semaphore 承担。— https://github.com/deepseek-ai/deepseek-harness/discussions/6372
allowBuilds
`apps/desktop/src/project-manager.ts` 里一份「允许执行安装期构建脚本」的白名单式声明,逐项列出 `node-pty`、`koffi`、`fs-ext` 等需要编译的原生依赖。它是一份刻意收窄的声明集而非通用 allow-list;留着一条指向「不可能被安装的包」的条目虽然自洽,却会让下一个读它的人误以为它仍然有效。— https://github.com/deepseek-ai/deepseek-harness/discussions/6372

来源