DeepSeek Harness prepare:dsh 失败:payload smoke 断言了已移除的 fs-ext
在 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',第一反应往往是「少装了个包」,但这条错误恰恰是反过来的:是检查在找一个已经被删掉的包。 两者处理方式完全相反,先用三行命令把范围钉死。
# 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:
// 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/137 | checkFsExt() 及其调用与汇总键 | 就是本次故障本体 |
apps/desktop/scripts/runtime-file-policy.ts:26/30 | fs-ext/build/** 排除规则 | 被 spec 覆盖,改要连 spec 一起改 |
apps/desktop/src/project-manager.ts:109 | allowBuilds: 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 通过了」在绿色运行里长得一模一样。
这条比第一条更值得单独讲,因为它解释了为什么一个从未成功过的断言能活到发版。
// apps/desktop/scripts/prepare-dsh.ts 附近(修复前)
else { process.stdout.write(stdout); accept() } // stdout 直写,不解析那行 JSON
评审者用 git grep 确认过:那些键(fsExt、koffi……)在整棵树里没有任何读取方。fsExt 键因此得以长期报 true,而没有任何地方会对此提出异议。把它说得再直白一点:
在修复前,从 fixture 里删掉任意一个
check*调用,绿色运行的表现与「它真的通过了」完全无法区分。
失败路径是好的,缺的是成功路径
需要公平地说清楚:失败路径没问题。
prepare-dsh.ts:144对任何非零退出都会reject,并把子进程stderr包进错误信息——补丁打坏了一定会响。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 中真实存在,保持不变」——评审者特意回头核对并明确背书了它,原因有两个:
- 迁移说明的 rejected-alternatives 表把「用 koffi 做 POSIX 调用」列为 fs-ext 的被拒替代。也就是说,被采纳的方向是预构建 Node-API addon,而「用 koffi 顶替 POSIX 那一半」是当时被否掉的方案。把
checkKoffi()当成旧方案删掉,正好是把「被拒」读成了「被采纳」。 - 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 导入一起删掉。
--- 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 + 改汇总形状
--- 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
--- 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)
两个刻意的设计决定
这两处是报告人明说「预计会被质疑」的地方,值得逐条解释:
- 用了集合相等,而不是「期望键都存在」。 校验条件同时检查
checks.length === EXPECTED.length和 每个上报项value === true且名字在期望表里。为什么不是单向?因为只查「期望的都在」的话,fixture 里新增了一个 check 却忘了更新期望表这种方向是漏的。集合相等把两个方向都关上。 - 解析错误被故意转成
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,没有任何一行去
requirenode-addon-system或解析它的平台包。
而这只 fixture 守护的失效模式正是「install 产出的原生布局与源树不同」,平台包契约又是其中最年轻、最少被走到的部分。为什么不能靠「打包」本身兜住?因为没有任何预构建二进制能跨 OS、CPU、libc 与 Node ABI 通用——这正是该家族被拆成按平台分包的原因。而缺平台包的失效会在裸 require 该子路径时立刻暴露,所以 payload 检查成本极低、不需要任何新的 fixture 机制。
跨平台语义必须写清楚
checkSystemFlock() 的行为在两个平台上是不对称的,这一点如果不注释清楚,很容易被后人误读成「Windows 也验过平台包」:
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、:38 | fs-ext 规则的保留向量 | 必须与规则同一批改 |
runtime-file-policy.spec.ts:73 | 嵌套路径正例 | 同上,删规则即变红 |
runtime-file-policy.ts:26/30 | fs-ext/build/** 排除规则 | 与 spec 同批 |
project-manager.ts:109 | allowBuilds: 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——说明在这台目标机上,记录的清单与实际落盘的树是对得上的。
三条附注(同样值得记住):
- Windows 确实能解析 payload 内的
./flock子路径,这正是新检查在 Windows 上断言的全部内容;system.node的字节仍只在 POSIX 车道被走到,因为 entry 包没有win32-*optionalDependency。将来若想收紧检查,必须先解决这个前提。 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续跑即可。- 下游打包路径也验证过:electron-builder 复制
resources/dsh/node_modules(node-pty 的 ConPTY helper、ripgrep),afterPack的verifyDesktopRuntime在成包树上通过,抽取副本的 smoke 从 profile 建出 241 个 junction、窗口约十秒起来。报告人说明这部分是他本地的未签名路线、不属于上游改动——面向上游的完整增量只有 fixture 与prepare-dsh.ts两个文件。
排查注意事项
- 看到
Cannot find module 'X'先问一句「X 现在还是依赖吗」。 本例里第一反应(装回去)是反向的;git grep -- '*package.json'与pnpm-lock.yaml两个命令就能定性。 - 别被「换台机器试试」带偏。 报告人用全新 temp project + 全新 store 连跑三次、三次同点失败——结构性问题不会因为环境干净而消失。
- 分清「断言不可能成功」与「断言可能失败但没跑」。 前者要删,后者要修 fixture 逻辑,处理方式不同。
- 迁移说明读成清单时,务必确认每一条的方向**。** fs-ext 是被移除的,koffi 是被保留的;把「被拒替代」读成「被采纳」,就会删错东西。
- 汇总行没人读 = 检查可以静默消失。 只要一个 check 的结果不进入任何判定,它就是在裸奔;把结果接进判定,比多写一个 check 更重要。
- 校验用集合相等,不要用「期望键都存在」。 单向校验只堵一个方向,新增 check 忘登记这种错误正好从另一个方向漏过去。
execFile回调里绝不要裸throw。 必须reject,否则 promise 永久 pending、把整个 prepare 挂死,比失败更难查。- 改与被测数据相关的规则时,先找它的 spec。
runtime-file-policy.ts的fs-ext规则单独删会让runtime-file-policy.spec.ts变红,必须同批。 allowBuilds这类白名单也会腐坏。 一条指向无法安装的包的条目看着无害,实际是下一个人的陷阱。- 平台包检查要注释清语义。 Windows 上「只解析子路径」和 POSIX 上「真锁一次」不是等价覆盖,写清楚才不会让后人把 Windows 绿灯当成平台包验证过了。
这只 smoke 真正的价值不是「多验一个包」,而是把「安装产出的原生布局」变成一份可读、可判定的契约。 如果你的二次分发里也内置 DSH 运行环境,或者你在 CI 里跑 prepare:dsh,建议顺手做两件事:把汇总行接进判定,让「下次删掉某个 check」变成一次显式失败;以及在下一次原生依赖迁移时,先确认每个名字的方向——被移除的删、被保留的留,尤其别把 koffi 这种仍在承载 Windows 锁的依赖当成旧包袱清掉。用 DSH Plugin Hub 的话,插件装卸、更新确认与系统日志都在一个面板里,排查这类环境问题能少绕几步。

来源:Discussion #6372。
常见问题
prepare:dsh 是桌面端在本地构建时用来生成 resources/dsh(打包进安装包的那份 Host 运行环境)的脚本,属于「从源码构建桌面端 / 打本地包」链路,普通用户从安装包装 DSH 不会走到它。你会在两种情况下遇到:一是你自己从 monorepo 出包,二是你维护的是内置了 DSH 运行环境的二次分发。普通插件安装遇到这类问题,看的是日志里的另一批错误码。
不建议,也基本不成立。全树没有任何 package.json 声明 fs-ext、pnpm-lock.yaml 里也没有条目,而 fixture 是在 payload 内用 pnpm install --prod --frozen-lockfile 装出来的——你手工补一个包,--frozen-lockfile 那一关照样过不去,下次全新 temp project + 全新 store 又会失败。真正的修法是删掉这个已经不可能成立的断言,而不是把被移除的依赖请回来。
因为它走的是另一条信任链。迁移说明里,源树一侧由该家族自带的 test/flock.test.js 覆盖;而这个 fixture 守护的是「安装产出的原生布局与源树不一致」这一失效模式,也就是说 payload 侧才是它该管的地方,原先却一条都没有。修法三补的 checkSystemFlock() 正是这条缺口。
因为那份迁移说明读起来像一张「移除清单」,很容易让人把它当成「被淘汰的旧方案」一并清掉。事实相反:说明的 rejected-alternatives 表把「用 koffi 做 POSIX 调用」列为 fs-ext 的**被拒替代**,即被采纳的方向是预构建 Node-API addon;同时说明里还有一节明确让 Windows 锁继续跑在既有的 koffi semaphore 上。koffi 在树内有 6 个 manifest 声明它,删掉就是用一个新的错误换掉一个已经修好的错误。
多半不是。报告人在 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
来源
- #6372 — [Bug] 0.1.5-rc.2 桌面端 prepare:dsh 必然失败:payload smoke 断言已被移除的 fs-ext,无法产出 resources/dsh· deepseek-ai(GitHub Discussions)
- deepseek-ai/deepseek-harness(源码仓库)· GitHub