feat: enhance desktop packaging process with self-contained harness verification
- Updated README and README.zh to clarify packaging commands and added verification step. - Improved packaging scripts to automatically build the self-contained runtime and verify its integrity. - Introduced new scripts for verifying harness self-containment and booting the web profile. - Added comprehensive tests for relinking harness symlinks and ensuring self-containment. - Updated package.json scripts to reflect new build and verification processes.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
# Agent Note: Self-contained harness links for the desktop bundle
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-15-desktop-harness-self-contained-links.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The desktop app promises to run detached from any development environment: a
|
||||
user installs the `.dmg`/`.nsis`, double-clicks, and gets a working `dsh`. That
|
||||
promise silently breaks on **Windows**: `scripts/build-harness.mjs` copies the
|
||||
repo's working `node_modules` (plus `vendor/packages/native/apps`) into
|
||||
`build/harness` with `cpSync(..., { recursive: true })`, and Node's copy with the
|
||||
default `dereference: false` recreates every symlink **verbatim** — target path
|
||||
string and all.
|
||||
|
||||
A pnpm workspace is held together by links inside `node_modules`. Without
|
||||
Windows Developer Mode, pnpm records workspace links as **junctions**, which
|
||||
store the **absolute path of the build machine's dev tree** (e.g.
|
||||
`C:\Users\…\deepseek-harness\packages\core\dsh-core`). Copied verbatim, the
|
||||
packaged harness keeps pointing at the dev tree: it reads/writes **development**
|
||||
resources when run on the build machine (silently "working" — the reported
|
||||
symptom) and its links dangle on any other machine, so the app cannot start.
|
||||
|
||||
Why the build machine cannot catch this by running the app: the dev path exists
|
||||
locally, so a junction always resolves. The only reliable check is a static link
|
||||
walk that asserts every link target resolves inside the bundle, plus booting the
|
||||
harness straight from the bundle.
|
||||
|
||||
## Decision
|
||||
|
||||
Make `build/harness` genuinely self-contained, in `apps/desktop`:
|
||||
|
||||
- **`scripts/harness-relink.mjs`** (new, pure, unit-tested): walks the assembled
|
||||
harness and rewrites every symlink/junction to a **relative target inside the
|
||||
harness**, mirroring the dev layout (`repoRoot → build/harness`). Targets
|
||||
inside the harness are left alone (idempotent); targets inside the repo are
|
||||
mirrored and relinked; a target whose directory contains the bundle, or whose
|
||||
in-tree target is absent (a workspace package the harness does not ship, e.g.
|
||||
the desktop shell itself or a dependency-resolution member like
|
||||
`python/sdk-runtime`), is **dropped** — it cannot be mirrored and is not part
|
||||
of the runtime closure; genuinely external targets are dereferenced (copied in
|
||||
as real files). `build-harness.mjs` re-runs the pass until no link changes
|
||||
(rewriting one link can expose another). `findEscapingLinks`/
|
||||
`assertSelfContained` prove the invariant: **no link may resolve outside the
|
||||
bundle or dangle** — the build fails otherwise. `probeSymlinkSupport` detects
|
||||
whether the host can create directory symlinks.
|
||||
- **`scripts/build-harness.mjs`**: refuses when a critical dir is missing
|
||||
(`node_modules` etc.) or the CLI/web dist is unbuilt (previously a silent skip
|
||||
shipped a broken harness); probes symlink capability and errors with guidance
|
||||
(Windows Developer Mode / admin), with a `--dereference-ok` escape hatch for
|
||||
locked-down hosts; after copying it runs the relink pass, `assertSelfContained`,
|
||||
and a `dsh --version` smoke test.
|
||||
- **`scripts/verify-harness.mjs`** (new): reusable verifier. Checks
|
||||
`build/harness`, or with `--app <dir>` a packed/unpacked app's
|
||||
`resources/harness` (covering electron-builder's `extraResources` copy);
|
||||
`--boot` spawns the web profile straight from the bundle (temp `DSH_HOME`,
|
||||
telemetry off) and waits for its readiness line.
|
||||
- **Wiring**: root `desktop:pack` now runs `build:harness` automatically (a stale
|
||||
or missing runtime cannot be shipped); new `desktop:verify` =
|
||||
`electron-builder --dir` + the verifier against the packed app.
|
||||
|
||||
Requiring symlink capability (rather than silently dereferencing) keeps the
|
||||
runtime compact: dereferencing the whole pnpm store would balloon the ≈2 GB
|
||||
harness to many times its size, since every shared dependency would be copied
|
||||
once per consumer.
|
||||
|
||||
## Verification
|
||||
|
||||
- `apps/desktop/tests/harness-relink.spec.ts` (vitest, cross-platform): relative
|
||||
in-tree link untouched; absolute/junction link rewritten to a relative in-tree
|
||||
link; `.pnpm` store mirror; external target dereferenced into real content;
|
||||
dangling link reported; `assertSelfContained` throws on escaping links;
|
||||
`probeSymlinkSupport` boolean.
|
||||
- Standalone probe on Windows (admin): absolute junction → relative in-tree
|
||||
symlink, external target → real files, `findEscapingLinks === []`.
|
||||
- Per-platform release gate: `pnpm desktop:pack` (must print relink stats +
|
||||
"self-contained"), then `pnpm desktop:verify --boot` on **both** Windows and
|
||||
macOS (on macOS the relink pass is mostly a no-op because pnpm uses relative
|
||||
symlinks; the check still proves the packaged copy is intact). The static link
|
||||
walk is authoritative on the build machine, where the dev path exists.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **`cpSync(..., { dereference: true })` (dereference everything).** Rejected:
|
||||
breaks hardlink dedup across the pnpm store, ballooning the ≈2 GB runtime to
|
||||
many times its size.
|
||||
- **Fix at install time (force relative symlinks).** Rejected: pnpm's
|
||||
junction-vs-symlink choice is machine-dependent and not reliably controllable.
|
||||
- **Runtime self-heal in the packaged app (re-point broken junctions to the
|
||||
app's own resources).** Rejected: junctions are always absolute, so a portable
|
||||
rewrite needs real relative symlinks on the target machine too — no simpler
|
||||
than fixing the bundle at build time, and it ships complexity into the app.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Costs:** building on Windows requires Developer Mode or an admin shell;
|
||||
`build:harness` takes longer (relink walk + smoke); release notes must mention
|
||||
the capability requirement.
|
||||
- **Buys:** a packaged app whose `resources/harness` provably references nothing
|
||||
outside the bundle — the "detached from the development environment" guarantee
|
||||
that is a hard requirement for production distribution. The existing
|
||||
`asar: false` / `npmRebuild: false` and the manual-download update flow are
|
||||
unchanged.
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
# Agent Note:桌面 bundle 的 harness 链接自包含化
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-15-desktop-harness-self-contained-links.md)
|
||||
|
||||
## 问题
|
||||
|
||||
桌面应用承诺脱离任何开发环境即可运行:用户装好 `.dmg`/`.nsis`,双击即得到可用的
|
||||
`dsh`。这一承诺在 **Windows** 上会静默失效:`scripts/build-harness.mjs` 用
|
||||
`cpSync(..., { recursive: true })` 把仓库工作树的 `node_modules`(连同
|
||||
`vendor/packages/native/apps`)复制进 `build/harness`,而 Node 复制在默认
|
||||
`dereference: false` 下会**逐字**重建每个符号链接——连同目标路径字符串。
|
||||
|
||||
pnpm workspace 靠 `node_modules` 内的链接维系。在未开启 Windows 开发者模式时,
|
||||
pnpm 会把 workspace 链接记录为 **junction**,其中保存的是**构建机开发树的绝对路径**
|
||||
(如 `C:\Users\…\deepseek-harness\packages\core\dsh-core`)。逐字复制后,打包出的
|
||||
harness 仍然指向开发树:在本机构建机上运行时会读写**开发**资源("看似正常"——即
|
||||
被报告的现场),换到任何其他机器则链接悬空、应用无法启动。
|
||||
|
||||
为什么构建机上"跑起来"证明不了正确性:本机开发路径存在,junction 总能解析。唯一
|
||||
可靠的检查是静态链接巡检——断言每个链接目标都解析到 bundle 内部——再加上从
|
||||
bundle 直接启动 harness 的冒烟测试。
|
||||
|
||||
## 决策
|
||||
|
||||
让 `build/harness` 真正做到自包含,改动全部在 `apps/desktop` 内:
|
||||
|
||||
- **`scripts/harness-relink.mjs`**(新增、纯逻辑、单测覆盖):遍历组装好的
|
||||
harness,把每个符号链接/junction 改写为**指向 harness 内部的相对目标**,镜像
|
||||
开发布局(`repoRoot → build/harness`)。目标已在 harness 内则保持不变(幂等);
|
||||
目标在仓库内则镜像后重链;目标所在目录包含 bundle 本身、或树内目标缺失(harness
|
||||
不随附的工作区包,如桌面外壳自身或 `python/sdk-runtime` 等依赖解析成员)则
|
||||
**丢弃**——无法镜像且不属于运行时闭包;真正外部目标则解引用(复制为真实文件)。
|
||||
`build-harness.mjs` 会重跑该过程直到不再有链接变化(改写一个链接可能暴露另一个)。
|
||||
`findEscapingLinks`/`assertSelfContained` 保证不变式:**任何链接都不得解析到
|
||||
bundle 之外或悬空**——否则构建失败。`probeSymlinkSupport` 探测宿主能否创建目录
|
||||
符号链接。
|
||||
- **`scripts/build-harness.mjs`**:关键目录缺失(`node_modules` 等)或 CLI/web
|
||||
dist 未构建时直接报错(此前静默跳过会打出坏包);探测符号链接能力,不具备时给出
|
||||
指引(Windows 开发者模式 / 管理员)并以 `--dereference-ok` 作为锁死机器的逃生舱;
|
||||
复制完成后执行 relink、`assertSelfContained` 与 `dsh --version` 冒烟。
|
||||
- **`scripts/verify-harness.mjs`**(新增):可复用验证器。默认校验 `build/harness`,
|
||||
带 `--app <dir>` 时校验打包/未打包 app 内的 `resources/harness`(覆盖
|
||||
electron-builder 的 `extraResources` 复制);`--boot` 直接从 bundle 拉起 web
|
||||
profile(临时 `DSH_HOME`、关闭遥测)并等待就绪行。
|
||||
- **接线**:根 `desktop:pack` 现在自动执行 `build:harness`(杜绝漏打运行时);
|
||||
新增 `desktop:verify` = `electron-builder --dir` + 对打包产物的验证器。
|
||||
|
||||
要求具备符号链接能力(而非静默解引用)是为了保持运行时体积:把整个 pnpm store
|
||||
解引用会把约 2GB 的 harness 放大数倍——每个共享依赖都会被按消费者数量复制一份。
|
||||
|
||||
## 验证
|
||||
|
||||
- `apps/desktop/tests/harness-relink.spec.ts`(vitest,跨平台):树内相对链接不动;
|
||||
绝对/junction 链接改写为树内相对链接;`.pnpm` store 镜像;外部目标解引用为真实
|
||||
内容;悬空链接被报告;`assertSelfContained` 对越界链接抛错;`probeSymlinkSupport`
|
||||
返回布尔。
|
||||
- Windows(管理员)独立探针:绝对 junction → 树内相对符号链接、外部目标 → 真实
|
||||
文件、`findEscapingLinks === []`。
|
||||
- 每平台发布门禁:`pnpm desktop:pack`(须打印 relink 统计与 "self-contained"),
|
||||
再在 **Windows 与 macOS 双端**跑 `pnpm desktop:verify --boot`(macOS 上 relink
|
||||
多为空操作,因为 pnpm 用相对符号链接,但检查仍证明打包副本完好)。在构建机上开发
|
||||
路径存在,因此**静态链接巡检**是权威检查。
|
||||
|
||||
## 备选方案
|
||||
|
||||
- **`cpSync(..., { dereference: true })`(全量解引用)。** 否决:破坏 pnpm store
|
||||
的硬链接去重,约 2GB 运行时被放大数倍。
|
||||
- **安装期修复(强制相对符号链接)。** 否决:pnpm 在 junction 与符号链接之间的选择
|
||||
依机器而定,无法可靠控制。
|
||||
- **打包后运行时自愈(把坏 junction 改指向 app 自身 resources)。** 否决:junction
|
||||
天生是绝对路径,可移植改写仍需目标机具备创建相对符号链接的能力——并不比在构建期
|
||||
修 bundle 更简单,还会把复杂度送进应用内。
|
||||
|
||||
## 后果
|
||||
|
||||
- **代价:** Windows 上构建需要开发者模式或管理员 shell;`build:harness` 变慢
|
||||
(relink 巡检 + 冒烟);发布说明需提及这一能力要求。
|
||||
- **收益:** 打包应用的 `resources/harness` 可被证明不引用 bundle 之外任何路径——
|
||||
"脱离开发环境可用"这一生产分发的硬性前提得到保证。现有 `asar: false` /
|
||||
`npmRebuild: false` 与手动下载更新流均不变。
|
||||
Reference in New Issue
Block a user