From bad70fa444b0146ba80c92ec6800fb9e00784aea Mon Sep 17 00:00:00 2001 From: Takoyaki White Date: Wed, 19 Aug 2026 04:38:06 +0800 Subject: [PATCH 01/14] fix(directory-picker-native): run win32 dialog worker under plain node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The source-plane worker launched through `--import tsx/esm` with a Windows absolute path, which Node's ESM loader misparses as an `e:` scheme URL and rejects with ERR_UNSUPPORTED_ESM_URL_SCHEME before the first IPC message — the UI then only sees "win32 folder dialog worker exited before reporting a result". The worker's modules use only erasable TS syntax, so launch it directly under Node's native type stripping (stable since 22.18, inside the engines range) with no loader chain. Also switch the built/source arm test to `pathname.endsWith('.ts')` so bundler query strings cannot misclassify source modules as built, and pin the spawn args plus a real dialog smoke against the source arm. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- ...08-19-win32-dialog-worker-source-launch.md | 74 +++++++++++++++++++ ...19-win32-dialog-worker-source-launch.zh.md | 63 ++++++++++++++++ .../host/directory-picker-native/package.json | 3 +- .../src/win32-dialog-host.ts | 17 +++-- .../tests/win32-dialog-host.spec.ts | 13 ++++ .../tests/win32-dialog.spec.ts | 5 +- pnpm-lock.yaml | 3 - 7 files changed, 166 insertions(+), 12 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md new file mode 100644 index 0000000000..da5ef0da21 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md @@ -0,0 +1,74 @@ +# Agent Note: Win32 dialog worker source launch drops the tsx bootstrap + +Status: implemented + +English | [中文](2026-08-19-win32-dialog-worker-source-launch.zh.md) + +## Problem + +On Windows, the source-plane folder dialog worker never started: the Web UI +reported `win32 folder dialog worker exited before reporting a result`. The +root cause is the launch vector, not koffi: `spawnDialogWorker`'s source arm +ran `node --import tsx/esm `. With a loader registered via +`--import`, Node's ESM loader resolves the entry as a URL, and a Windows +absolute path (`E:\...`) becomes an `e:` scheme URL, throwing +`ERR_UNSUPPORTED_ESM_URL_SCHEME` before the worker's first IPC message. The +driver then surfaces only its generic exit error. Packaged consumers were +unaffected because they launch the built `worker.cjs` under plain node; the +bug hit every source launch (`pnpm dsh web`) on Windows. + +CI missed it for a second, compounding reason: the built/source arm choice +tested `import.meta.url.endsWith('.ts')`, and under Vitest/Vite the URL carries +a query string (`?v=...`), so the win32 smoke test silently exercised the built +arm — never the broken source launch. + +## Decision + +Run the source worker under plain node with native type stripping, no tsx +bootstrap: + +```ts +spawn(process.execPath, [fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], ...) +``` + +This is safe because the worker's dependency chain (worker, bindings, logic) +uses only erasable TypeScript syntax — no parameter properties, decorators, or +value namespaces — unlike the CLI source graph that keeps the tsx ESM hook. +Native type stripping is stable since Node 22.18, inside the engines range +(`^22.19.0 || >=24.0.0`). The built arm (`worker.cjs` under plain node) is +unchanged. + +The arm choice now reads `new URL(import.meta.url).pathname.endsWith('.ts')` so +bundler query strings cannot misclassify source modules as built. This makes +the win32 smoke test exercise the real source launch, and the new +`win32-dialog-host.spec.ts` case pins that the source arm passes the worker +path positionally with no `--import` flag. + +## Alternatives considered + +**Pass the worker as a `file://` URL instead of a path.** Rejected: tsx's +tsconfig-paths hook mangles `file://` URLs into `\file:\` +(`ERR_MODULE_NOT_FOUND`); keeping any tsx involvement leaves a fragile launch. + +**Probe koffi availability and fall back to pure-Node dialogs.** Out of scope: +dshcode pins koffi 3.1.1, which predates the broken 3.1.3/3.1.4 win32-x64 +prebuilds, so the worker's koffi usage is not the failure on this codebase; the +worker itself crashed before koffi ever loaded. + +## Consequences + +- Windows source launches (`pnpm dsh web`) open the folder dialog again; the + failure mode (`e:` scheme URL) is gone with the loader chain. +- No functional change for packaged hosts or POSIX: they already ran the worker + under plain node. +- The win32 smoke test now covers the source arm end to end; a regression to a + loader-bootstrapped launch is caught by both the spawn-args pin and the real + dialog smoke. + +## Verification + +`packages/host/directory-picker-native/tests/win32-dialog.spec.ts` opens and +abort-closes a real dialog through the source launch. The new +`win32-dialog-host.spec.ts` case asserts the positional worker path with no +`--import`. Full package suite: 48 passed, 1 skipped (the win32-skipped +built-worker e2e). diff --git a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md new file mode 100644 index 0000000000..f74012d482 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md @@ -0,0 +1,63 @@ +# Agent Note: Win32 对话框 worker 源码启动去掉 tsx 引导 + +Status: implemented + +[English](2026-08-19-win32-dialog-worker-source-launch.md) | 中文 + +## 问题 + +在 Windows 上,源码层面的文件夹对话框 worker 从未成功启动:Web UI 报 +`win32 folder dialog worker exited before reporting a result`。根因在启动方式而非 +koffi:`spawnDialogWorker` 的源码分支以 `node --import tsx/esm <绝对路径 .ts>` +运行。通过 `--import` 注册 loader 后,Node 的 ESM loader 会把入口当作 URL +解析,Windows 绝对路径(`E:\...`)变成 `e:` 协议 URL,在 worker 发出第一条 +IPC 消息之前就抛出 `ERR_UNSUPPORTED_ESM_URL_SCHEME`。驱动端于是只上报笼统的 +退出错误。打包用户不受影响,因为他们以纯 node 启动构建产物 `worker.cjs`; +该 bug 命中 Windows 上每一次源码启动(`pnpm dsh web`)。 + +CI 漏掉它还有第二个叠加原因:built/源码分支选择用的是 +`import.meta.url.endsWith('.ts')`,而在 Vitest/Vite 下 URL 带查询串(`?v=...`), +win32 冒烟测试静默地走了 built 分支——从未覆盖坏掉的源码启动。 + +## 决策 + +源码 worker 以纯 node + 原生类型剥离运行,不再经过 tsx 引导: + +```ts +spawn(process.execPath, [fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], ...) +``` + +这是安全的,因为 worker 的依赖链(worker、bindings、logic)只用可擦除的 +TypeScript 语法——没有参数属性、装饰器或值命名空间——不像保留 tsx ESM +hook 的 CLI 源码图。原生类型剥离自 Node 22.18 起稳定,落在 engines 范围 +(`^22.19.0 || >=24.0.0`)内。built 分支(纯 node 跑 `worker.cjs`)不变。 + +分支选择改为 `new URL(import.meta.url).pathname.endsWith('.ts')`,bundler +查询串无法再把源码模块误判为 built 产物。这让 win32 冒烟测试真正覆盖源码 +启动;新增的 `win32-dialog-host.spec.ts` 用例固定源码分支以位置参数传入 +worker 路径且不带 `--import`。 + +## 考虑过的备选方案 + +**把 worker 作为 `file://` URL 而非路径传入。**拒绝:tsx 的 tsconfig-paths +钩子会把 `file://` URL 破坏成 `\file:\` +(`ERR_MODULE_NOT_FOUND`);只要还沾 tsx,启动就是脆弱的。 + +**探测 koffi 可用性并回退到纯 Node 对话框。**超出范围:dshcode 固定 +koffi 3.1.1,早于损坏的 3.1.3/3.1.4 win32-x64 预编译,worker 的 koffi 用法 +在本代码库并非故障点;worker 在 koffi 加载之前就已崩溃。 + +## 影响 + +- Windows 源码启动(`pnpm dsh web`)重新能弹出文件夹对话框;`e:` 协议 + URL 这一失败模式随 loader 链一起消失。 +- 打包宿主与 POSIX 无功能变化:它们本来就在纯 node 下运行 worker。 +- win32 冒烟测试现在端到端覆盖源码分支;回归到 loader 引导启动会同时被 + spawn 参数固定与真实对话框冒烟测试抓住。 + +## 验证 + +`packages/host/directory-picker-native/tests/win32-dialog.spec.ts` 通过源码 +启动真实打开并中止关闭对话框。新增的 `win32-dialog-host.spec.ts` 用例断言 +位置参数的 worker 路径且不带 `--import`。包内完整测试套件:48 通过, +1 跳过(win32 跳过的 built-worker e2e)。 diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index dcbaf930ea..68f2beb2d4 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -47,7 +47,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "tsx": "^4.19.2" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/host/directory-picker-native/src/win32-dialog-host.ts b/packages/host/directory-picker-native/src/win32-dialog-host.ts index d4277b4aad..f238dd9376 100644 --- a/packages/host/directory-picker-native/src/win32-dialog-host.ts +++ b/packages/host/directory-picker-native/src/win32-dialog-host.ts @@ -14,9 +14,10 @@ import type { Win32DialogWorkerData } from './win32-dialog-worker.ts' /** * Spawn the dialog child process. Built consumers launch the bundled CJS * entry next to this module under plain node; unbuilt (source) consumers - * bootstrap tsx first, mirroring the dsh CLI's source launch. The dialog is - * the child's first window, so Windows activates it without a foreground - * call. + * run the worker directly under Node's native type stripping (stable since + * 22.18, covered by the engines range) — the worker's three modules use + * only erasable TS syntax, so no tsx bootstrap is needed. The dialog is the + * child's first window, so Windows activates it without a foreground call. * @param data - the child payload (dialog title). * @returns the spawned child process. */ @@ -25,11 +26,17 @@ export function spawnDialogWorker(data: Win32DialogWorkerData): ReturnType`: Node's ESM loader accepts a file path here (no + // tsx hook in front of it), so the absolute Windows path cannot be misparsed + // as an `e:` scheme URL — the failure mode when tsx's loader chain is active. + return spawn(process.execPath, [fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], { env, stdio, windowsHide: true }) } export { closeThreadWindows } from './win32-dialog-bindings.ts' diff --git a/packages/host/directory-picker-native/tests/win32-dialog-host.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog-host.spec.ts index c8f785f6c1..aa169772ce 100644 --- a/packages/host/directory-picker-native/tests/win32-dialog-host.spec.ts +++ b/packages/host/directory-picker-native/tests/win32-dialog-host.spec.ts @@ -1,4 +1,5 @@ import type { ChildProcess, SpawnOptions } from 'node:child_process' +import { fileURLToPath } from 'node:url' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' type SpawnWorker = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess @@ -39,4 +40,16 @@ describe('spawnDialogWorker', () => { }) expect(process.env.ELECTRON_RUN_AS_NODE).toBe('') }) + + // Regression guard for the source plane: the worker must launch under plain + // node with native type stripping (no tsx bootstrap), so the absolute Windows + // path is a positional file argument, not a URL through a loader chain. + it('launches the source worker under plain node with no loader flags', () => { + spawnDialogWorker({ title: 'Source-plane guard' }) + + expect(spawnMock).toHaveBeenCalledOnce() + const args = spawnMock.mock.calls[0]?.[1] + expect(args).toEqual([fileURLToPath(new URL('../src/win32-dialog-worker.ts', import.meta.url))]) + expect(args).not.toContain('--import') + }) }) diff --git a/packages/host/directory-picker-native/tests/win32-dialog.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts index 8e7d6951b8..99d4ed932a 100644 --- a/packages/host/directory-picker-native/tests/win32-dialog.spec.ts +++ b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts @@ -145,8 +145,9 @@ describe('pickWin32Directory', () => { expect(close.mock.calls.length).toBeGreaterThan(10) }) - // POSIX hosts exercise the REAL default plumbing end to end: the tsx-bootstrapped - // worker spawns, loads koffi, fails to load ole32.dll, and reports the error. + // POSIX hosts exercise the REAL default plumbing end to end: the source + // worker spawns under plain node with native type stripping (no tsx + // bootstrap), loads koffi, fails to load ole32.dll, and reports the error. it.skipIf(process.platform === 'win32')('rejects through the real worker where the Win32 surface is unavailable', async () => { await expect(pickWin32Directory(live())).rejects.toThrow('win32 folder dialog failed') }, 30_000) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2860dc4eb0..367b2fa516 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5198,9 +5198,6 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - tsx: - specifier: ^4.19.2 - version: 4.22.4 packages/host/frontend-static: dependencies: From f16e1fb841081bcf58cb11089af5ba575a87b1d1 Mon Sep 17 00:00:00 2001 From: Takoyaki White <74053919+takoyakiwhite@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:22:22 +0800 Subject: [PATCH 02/14] fix(directory-picker-native): address PR review feedback --- ...in32-dialog-worker-source-launch.i18n.yaml | 6 ++ ...08-19-win32-dialog-worker-source-launch.md | 83 ++++++++----------- ...19-win32-dialog-worker-source-launch.zh.md | 65 ++++++--------- .../src/win32-dialog-host.ts | 36 ++++++-- .../tests/win32-dialog-host.spec.ts | 14 +++- 5 files changed, 105 insertions(+), 99 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.i18n.yaml diff --git a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.i18n.yaml new file mode 100644 index 0000000000..b1649a16fa --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md +2026-08-19-win32-dialog-worker-source-launch.md: 65cfbdb0370b011782c2188d114f07e45a1ba700 +2026-08-19-win32-dialog-worker-source-launch.zh.md: 5d0d5cc397ac571d04122313a6197e7369ad5d2c diff --git a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md index da5ef0da21..65cfbdb037 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md +++ b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md @@ -6,69 +6,54 @@ English | [中文](2026-08-19-win32-dialog-worker-source-launch.zh.md) ## Problem -On Windows, the source-plane folder dialog worker never started: the Web UI -reported `win32 folder dialog worker exited before reporting a result`. The -root cause is the launch vector, not koffi: `spawnDialogWorker`'s source arm -ran `node --import tsx/esm `. With a loader registered via -`--import`, Node's ESM loader resolves the entry as a URL, and a Windows -absolute path (`E:\...`) becomes an `e:` scheme URL, throwing -`ERR_UNSUPPORTED_ESM_URL_SCHEME` before the worker's first IPC message. The -driver then surfaces only its generic exit error. Packaged consumers were -unaffected because they launch the built `worker.cjs` under plain node; the -bug hit every source launch (`pnpm dsh web`) on Windows. - -CI missed it for a second, compounding reason: the built/source arm choice -tested `import.meta.url.endsWith('.ts')`, and under Vitest/Vite the URL carries -a query string (`?v=...`), so the win32 smoke test silently exercised the built -arm — never the broken source launch. +On Windows, the source-plane folder dialog worker never started: the Web UI reported +`win32 folder dialog worker exited before reporting a result`. The failure was in the +launch vector, not koffi: the source arm used `node --import tsx/esm `. +With a loader registered through `--import`, a Windows absolute path can be interpreted +as an `e:` scheme URL and rejected with `ERR_UNSUPPORTED_ESM_URL_SCHEME` before the worker +posts its first IPC message. ## Decision -Run the source worker under plain node with native type stripping, no tsx -bootstrap: +Run the source worker directly under Node's native type stripping: ```ts spawn(process.execPath, [fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], ...) ``` -This is safe because the worker's dependency chain (worker, bindings, logic) -uses only erasable TypeScript syntax — no parameter properties, decorators, or -value namespaces — unlike the CLI source graph that keeps the tsx ESM hook. -Native type stripping is stable since Node 22.18, inside the engines range -(`^22.19.0 || >=24.0.0`). The built arm (`worker.cjs` under plain node) is -unchanged. +The repository requires `^22.19.0 || >=24.0.0`, and this worker dependency graph is +package-local: the worker, bindings, and logic modules import no workspace packages. +The graph uses only erasable TypeScript syntax, so no tsx hook is required for this +source-plane worker. The packaged arm remains `worker.cjs` under plain node. -The arm choice now reads `new URL(import.meta.url).pathname.endsWith('.ts')` so -bundler query strings cannot misclassify source modules as built. This makes -the win32 smoke test exercise the real source launch, and the new -`win32-dialog-host.spec.ts` case pins that the source arm passes the worker -path positionally with no `--import` flag. +The source/built arm now uses `new URL(import.meta.url).pathname.endsWith('.ts')`. +The query-string issue is therefore treated as a bundler-specific test hazard, not as +a POSIX runtime root cause: Vitest/Vite can decorate module URLs, while ordinary POSIX +Node execution does not reproduce that decoration. -## Alternatives considered +## Runtime inheritance -**Pass the worker as a `file://` URL instead of a path.** Rejected: tsx's -tsconfig-paths hook mangles `file://` URLs into `\file:\` -(`ERR_MODULE_NOT_FOUND`); keeping any tsx involvement leaves a fragile launch. +A source worker can inherit `NODE_OPTIONS` from the host. Both spellings used across +the supported Node range that disable native type stripping are removed from the child +environment: -**Probe koffi availability and fall back to pure-Node dialogs.** Out of scope: -dshcode pins koffi 3.1.1, which predates the broken 3.1.3/3.1.4 win32-x64 -prebuilds, so the worker's koffi usage is not the failure on this codebase; the -worker itself crashed before koffi ever loaded. +- `--no-experimental-strip-types` +- `--no-strip-types` -## Consequences +Other `NODE_OPTIONS` entries are preserved. This prevents a host-level setting from +turning the fixed Windows launch back into the same generic worker-exit symptom. -- Windows source launches (`pnpm dsh web`) open the folder dialog again; the - failure mode (`e:` scheme URL) is gone with the loader chain. -- No functional change for packaged hosts or POSIX: they already ran the worker - under plain node. -- The win32 smoke test now covers the source arm end to end; a regression to a - loader-bootstrapped launch is caught by both the spawn-args pin and the real - dialog smoke. +## Related launch paths + +The CLI source graph still uses tsx because it has a broader runtime dependency graph; +that is an intentional separate case. The packaged dialog worker is already CJS and does +not need the source-plane treatment. No other directory-picker worker uses this +Windows-absolute-path-plus-`--import` launch vector. ## Verification -`packages/host/directory-picker-native/tests/win32-dialog.spec.ts` opens and -abort-closes a real dialog through the source launch. The new -`win32-dialog-host.spec.ts` case asserts the positional worker path with no -`--import`. Full package suite: 48 passed, 1 skipped (the win32-skipped -built-worker e2e). +- The spawn unit test pins the source worker path as a positional argument and rejects + loader flags. +- The spawn unit test verifies both type-stripping-disabling `NODE_OPTIONS` spellings + are removed while unrelated options are preserved. +- The existing Win32 smoke test continues to cover the real source-plane dialog. diff --git a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md index f74012d482..5d0d5cc397 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md @@ -6,58 +6,47 @@ Status: implemented ## 问题 -在 Windows 上,源码层面的文件夹对话框 worker 从未成功启动:Web UI 报 -`win32 folder dialog worker exited before reporting a result`。根因在启动方式而非 -koffi:`spawnDialogWorker` 的源码分支以 `node --import tsx/esm <绝对路径 .ts>` -运行。通过 `--import` 注册 loader 后,Node 的 ESM loader 会把入口当作 URL -解析,Windows 绝对路径(`E:\...`)变成 `e:` 协议 URL,在 worker 发出第一条 -IPC 消息之前就抛出 `ERR_UNSUPPORTED_ESM_URL_SCHEME`。驱动端于是只上报笼统的 -退出错误。打包用户不受影响,因为他们以纯 node 启动构建产物 `worker.cjs`; -该 bug 命中 Windows 上每一次源码启动(`pnpm dsh web`)。 - -CI 漏掉它还有第二个叠加原因:built/源码分支选择用的是 -`import.meta.url.endsWith('.ts')`,而在 Vitest/Vite 下 URL 带查询串(`?v=...`), -win32 冒烟测试静默地走了 built 分支——从未覆盖坏掉的源码启动。 +Windows 上源码层面的文件夹对话框 worker 无法启动:Web UI 只能看到 +`win32 folder dialog worker exited before reporting a result`。问题出在启动向量而非 +koffi:源码分支之前使用 `node --import tsx/esm <绝对路径 .ts>`。注册 `--import` +loader 后,Windows 绝对路径可能被按 `e:` scheme URL 处理,在 worker 发出第一条 IPC +消息前就因 `ERR_UNSUPPORTED_ESM_URL_SCHEME` 退出。 ## 决策 -源码 worker 以纯 node + 原生类型剥离运行,不再经过 tsx 引导: +源码 worker 直接由 Node 原生 TypeScript 类型剥离运行: ```ts spawn(process.execPath, [fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], ...) ``` -这是安全的,因为 worker 的依赖链(worker、bindings、logic)只用可擦除的 -TypeScript 语法——没有参数属性、装饰器或值命名空间——不像保留 tsx ESM -hook 的 CLI 源码图。原生类型剥离自 Node 22.18 起稳定,落在 engines 范围 -(`^22.19.0 || >=24.0.0`)内。built 分支(纯 node 跑 `worker.cjs`)不变。 +仓库 engines 为 `^22.19.0 || >=24.0.0`;该 worker 的依赖图是包内闭合的,worker、 +bindings、logic 都不导入 workspace 包,只使用可擦除的 TypeScript 语法,因此源码 +worker 不需要 tsx hook。打包分支继续由纯 node 启动 `worker.cjs`。 -分支选择改为 `new URL(import.meta.url).pathname.endsWith('.ts')`,bundler -查询串无法再把源码模块误判为 built 产物。这让 win32 冒烟测试真正覆盖源码 -启动;新增的 `win32-dialog-host.spec.ts` 用例固定源码分支以位置参数传入 -worker 路径且不带 `--import`。 +源码/构建分支判断改用 `new URL(import.meta.url).pathname.endsWith('.ts')`。这里把 +查询串问题明确限定为 bundler 测试环境的风险,而不是 POSIX 运行时的根因: +Vitest/Vite 可能给模块 URL 添加查询串,普通 POSIX Node 执行不会复现这一点。 -## 考虑过的备选方案 +## 运行时环境继承 -**把 worker 作为 `file://` URL 而非路径传入。**拒绝:tsx 的 tsconfig-paths -钩子会把 `file://` URL 破坏成 `\file:\` -(`ERR_MODULE_NOT_FOUND`);只要还沾 tsx,启动就是脆弱的。 +源码 worker 会继承宿主的 `NODE_OPTIONS`。为避免宿主设置重新关闭 Node 原生类型 +剥离,同时兼容支持范围内的两种写法,子进程会移除: -**探测 koffi 可用性并回退到纯 Node 对话框。**超出范围:dshcode 固定 -koffi 3.1.1,早于损坏的 3.1.3/3.1.4 win32-x64 预编译,worker 的 koffi 用法 -在本代码库并非故障点;worker 在 koffi 加载之前就已崩溃。 +- `--no-experimental-strip-types` +- `--no-strip-types` -## 影响 +其他 `NODE_OPTIONS` 参数保持不变。这样不会因为宿主环境变量再次把修复后的 +Windows 启动打回同一个笼统的 worker 退出错误。 -- Windows 源码启动(`pnpm dsh web`)重新能弹出文件夹对话框;`e:` 协议 - URL 这一失败模式随 loader 链一起消失。 -- 打包宿主与 POSIX 无功能变化:它们本来就在纯 node 下运行 worker。 -- win32 冒烟测试现在端到端覆盖源码分支;回归到 loader 引导启动会同时被 - spawn 参数固定与真实对话框冒烟测试抓住。 +## 相关启动路径 + +CLI 源码图仍然保留 tsx,因为它的运行时依赖图更大,这是有意的独立场景。打包后的 +dialog worker 已经是 CJS,也不需要这套源码处理。directory-picker 内没有其他采用 +“Windows 绝对路径 + `--import` loader”这一启动向量的 worker。 ## 验证 -`packages/host/directory-picker-native/tests/win32-dialog.spec.ts` 通过源码 -启动真实打开并中止关闭对话框。新增的 `win32-dialog-host.spec.ts` 用例断言 -位置参数的 worker 路径且不带 `--import`。包内完整测试套件:48 通过, -1 跳过(win32 跳过的 built-worker e2e)。 +- spawn 单测固定源码 worker 以位置参数传入,并拒绝 loader 参数。 +- spawn 单测验证两种关闭类型剥离的 `NODE_OPTIONS` 写法都会被移除,同时保留无关参数。 +- 现有 Win32 smoke test 继续覆盖真实源码 worker 的对话框启动。 diff --git a/packages/host/directory-picker-native/src/win32-dialog-host.ts b/packages/host/directory-picker-native/src/win32-dialog-host.ts index f238dd9376..8e8ba76db6 100644 --- a/packages/host/directory-picker-native/src/win32-dialog-host.ts +++ b/packages/host/directory-picker-native/src/win32-dialog-host.ts @@ -11,12 +11,28 @@ import { spawn, type StdioOptions } from 'node:child_process' import { fileURLToPath } from 'node:url' import type { Win32DialogWorkerData } from './win32-dialog-worker.ts' +const NODE_TYPE_STRIPPING_DISABLE_FLAGS = /(?:^|\s)--(?:no-experimental-strip-types|no-strip-types)(?=\s|$)/g + +/** + * Remove Node options that disable native TypeScript type stripping. The + * worker source plane relies on Node's native type stripping, and inherited + * NODE_OPTIONS can otherwise restore the same generic worker-exit failure. + */ +function sanitizeNodeOptions(value: string | undefined): string | undefined { + if (!value) return value + const sanitized = value + .replace(NODE_TYPE_STRIPPING_DISABLE_FLAGS, ' ') + .replace(/\s{2,}/g, ' ') + .trim() + return sanitized === '' ? undefined : sanitized +} + /** * Spawn the dialog child process. Built consumers launch the bundled CJS * entry next to this module under plain node; unbuilt (source) consumers * run the worker directly under Node's native type stripping (stable since - * 22.18, covered by the engines range) — the worker's three modules use - * only erasable TS syntax, so no tsx bootstrap is needed. The dialog is the + * 22.18, covered by the engines range) — the worker's dependency graph is + * package-local and uses only erasable TypeScript syntax. The dialog is the * child's first window, so Windows activates it without a foreground call. * @param data - the child payload (dialog title). * @returns the spawned child process. @@ -24,18 +40,20 @@ import type { Win32DialogWorkerData } from './win32-dialog-worker.ts' export function spawnDialogWorker(data: Win32DialogWorkerData): ReturnType { // A packaged Electron host uses its branded application as process.execPath; // child-only Node mode bypasses application startup and its single-instance lock. - const env = { ...process.env, DSH_DIALOG_TITLE: data.title, ELECTRON_RUN_AS_NODE: '1' } + const env = { + ...process.env, + DSH_DIALOG_TITLE: data.title, + ELECTRON_RUN_AS_NODE: '1', + NODE_OPTIONS: sanitizeNodeOptions(process.env.NODE_OPTIONS), + } const stdio: StdioOptions = ['ignore', 'inherit', 'inherit', 'ipc'] - // Pathname (not the raw URL): bundlers/tests append query strings (?v=...) to - // the URL, which would misclassify source modules as built and silently test - // the wrong arm. The pathname ends with .ts only for the unbuilt source plane. + // Use pathname (not the raw URL): bundlers/tests may append query strings, + // which are not part of the source file extension. /* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/) */ if (!new URL(import.meta.url).pathname.endsWith('.ts')) { return spawn(process.execPath, [fileURLToPath(new URL('./worker.cjs', import.meta.url))], { env, stdio, windowsHide: true }) } - // `node `: Node's ESM loader accepts a file path here (no - // tsx hook in front of it), so the absolute Windows path cannot be misparsed - // as an `e:` scheme URL — the failure mode when tsx's loader chain is active. + // `node `: no loader hook is inserted before the path. return spawn(process.execPath, [fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], { env, stdio, windowsHide: true }) } diff --git a/packages/host/directory-picker-native/tests/win32-dialog-host.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog-host.spec.ts index aa169772ce..650c05c059 100644 --- a/packages/host/directory-picker-native/tests/win32-dialog-host.spec.ts +++ b/packages/host/directory-picker-native/tests/win32-dialog-host.spec.ts @@ -41,9 +41,17 @@ describe('spawnDialogWorker', () => { expect(process.env.ELECTRON_RUN_AS_NODE).toBe('') }) - // Regression guard for the source plane: the worker must launch under plain - // node with native type stripping (no tsx bootstrap), so the absolute Windows - // path is a positional file argument, not a URL through a loader chain. + it('removes inherited flags that disable native TypeScript stripping', () => { + vi.stubEnv('NODE_OPTIONS', '--max-old-space-size=256 --no-experimental-strip-types --trace-warnings --no-strip-types') + + spawnDialogWorker({ title: 'NODE_OPTIONS guard' }) + + expect(spawnMock).toHaveBeenCalledOnce() + const options = spawnMock.mock.calls[0]?.[2] + expect(options?.env?.NODE_OPTIONS).toBe('--max-old-space-size=256 --trace-warnings') + expect(process.env.NODE_OPTIONS).toBe('--max-old-space-size=256 --no-experimental-strip-types --trace-warnings --no-strip-types') + }) + it('launches the source worker under plain node with no loader flags', () => { spawnDialogWorker({ title: 'Source-plane guard' }) From 951400bc84fa89e6261e50091e853413fc153994 Mon Sep 17 00:00:00 2001 From: Takoyaki White <74053919+takoyakiwhite@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:37:38 +0800 Subject: [PATCH 03/14] fix(directory-picker-native): scope node option sanitization --- .../src/win32-dialog-host.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/host/directory-picker-native/src/win32-dialog-host.ts b/packages/host/directory-picker-native/src/win32-dialog-host.ts index 8e8ba76db6..22a6e5ddd9 100644 --- a/packages/host/directory-picker-native/src/win32-dialog-host.ts +++ b/packages/host/directory-picker-native/src/win32-dialog-host.ts @@ -31,29 +31,27 @@ function sanitizeNodeOptions(value: string | undefined): string | undefined { * Spawn the dialog child process. Built consumers launch the bundled CJS * entry next to this module under plain node; unbuilt (source) consumers * run the worker directly under Node's native type stripping (stable since - * 22.18, covered by the engines range) — the worker's dependency graph is - * package-local and uses only erasable TypeScript syntax. The dialog is the - * child's first window, so Windows activates it without a foreground call. + * 22.18, covered by the engines range). The source worker dependency graph + * is package-local and uses erasable TS with type-only relative imports. + * The dialog is the child's first window, so Windows activates it without a + * foreground call. * @param data - the child payload (dialog title). * @returns the spawned child process. */ export function spawnDialogWorker(data: Win32DialogWorkerData): ReturnType { // A packaged Electron host uses its branded application as process.execPath; // child-only Node mode bypasses application startup and its single-instance lock. - const env = { - ...process.env, - DSH_DIALOG_TITLE: data.title, - ELECTRON_RUN_AS_NODE: '1', - NODE_OPTIONS: sanitizeNodeOptions(process.env.NODE_OPTIONS), - } + const baseEnv = { ...process.env, DSH_DIALOG_TITLE: data.title, ELECTRON_RUN_AS_NODE: '1' } const stdio: StdioOptions = ['ignore', 'inherit', 'inherit', 'ipc'] // Use pathname (not the raw URL): bundlers/tests may append query strings, // which are not part of the source file extension. /* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/) */ if (!new URL(import.meta.url).pathname.endsWith('.ts')) { - return spawn(process.execPath, [fileURLToPath(new URL('./worker.cjs', import.meta.url))], { env, stdio, windowsHide: true }) + return spawn(process.execPath, [fileURLToPath(new URL('./worker.cjs', import.meta.url))], { env: baseEnv, stdio, windowsHide: true }) } - // `node `: no loader hook is inserted before the path. + // `node `: no loader hook is inserted before the path, so an + // absolute Windows path cannot be misparsed as an `e:` scheme URL. + const env = { ...baseEnv, NODE_OPTIONS: sanitizeNodeOptions(process.env.NODE_OPTIONS) } return spawn(process.execPath, [fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], { env, stdio, windowsHide: true }) } From 66361ac4c388cb569c3b1405d89fd79be917f417 Mon Sep 17 00:00:00 2001 From: Takoyaki White <74053919+takoyakiwhite@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:37:51 +0800 Subject: [PATCH 04/14] test(directory-picker-native): cover node option sanitizer --- .../tests/win32-dialog-host.spec.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/host/directory-picker-native/tests/win32-dialog-host.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog-host.spec.ts index 650c05c059..b1fb6b5701 100644 --- a/packages/host/directory-picker-native/tests/win32-dialog-host.spec.ts +++ b/packages/host/directory-picker-native/tests/win32-dialog-host.spec.ts @@ -52,6 +52,22 @@ describe('spawnDialogWorker', () => { expect(process.env.NODE_OPTIONS).toBe('--max-old-space-size=256 --no-experimental-strip-types --trace-warnings --no-strip-types') }) + it('drops NODE_OPTIONS entirely when it carried only the disabling flag', () => { + vi.stubEnv('NODE_OPTIONS', '--no-strip-types') + + spawnDialogWorker({ title: 'NODE_OPTIONS sole-flag guard' }) + + expect(spawnMock.mock.calls[0]?.[2]?.env?.NODE_OPTIONS).toBeUndefined() + }) + + it('passes no NODE_OPTIONS when the host did not set one', () => { + vi.stubEnv('NODE_OPTIONS', undefined) + + spawnDialogWorker({ title: 'NODE_OPTIONS unset guard' }) + + expect(spawnMock.mock.calls[0]?.[2]?.env?.NODE_OPTIONS).toBeUndefined() + }) + it('launches the source worker under plain node with no loader flags', () => { spawnDialogWorker({ title: 'Source-plane guard' }) From 40a392b0b315c51f736291429b779485fbb83e1c Mon Sep 17 00:00:00 2001 From: Takoyaki White <74053919+takoyakiwhite@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:38:02 +0800 Subject: [PATCH 05/14] docs: restore win32 worker agent note sections --- ...08-19-win32-dialog-worker-source-launch.md | 80 +++++++++++++++---- 1 file changed, 65 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md index 65cfbdb037..526bb7dc4a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md +++ b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md @@ -13,6 +13,11 @@ With a loader registered through `--import`, a Windows absolute path can be inte as an `e:` scheme URL and rejected with `ERR_UNSUPPORTED_ESM_URL_SCHEME` before the worker posts its first IPC message. +CI also had a test-selection blind spot: Vitest/Vite may append a query string to +`import.meta.url`, so a raw `import.meta.url.endsWith('.ts')` check can select the built +arm during source-plane tests. This is a bundler-specific test hazard, not a POSIX runtime +root cause. + ## Decision Run the source worker directly under Node's native type stripping: @@ -23,13 +28,20 @@ spawn(process.execPath, [fileURLToPath(new URL('./win32-dialog-worker.ts', impor The repository requires `^22.19.0 || >=24.0.0`, and this worker dependency graph is package-local: the worker, bindings, and logic modules import no workspace packages. -The graph uses only erasable TypeScript syntax, so no tsx hook is required for this -source-plane worker. The packaged arm remains `worker.cjs` under plain node. +The graph is erasable-only **with type-only relative imports**; `tsconfig.base.json` +uses `verbatimModuleSyntax: false`, so value-position type imports are not automatically +rewritten into type-only imports and would be unsafe for direct native stripping. + +This is backed by existing repository precedent rather than a novel launch mode: +`packages/code-runtime/code-runtime-worker-thread/src/index.ts` already loads its +source worker directly under native type stripping and requires an erasable-only graph +with type-only relative imports. `docs/testing.md#test-subprocess-launch-modes` also +explicitly permits erasable `.ts` subprocesses to run directly with Node without tsx or +the root paths map. -The source/built arm now uses `new URL(import.meta.url).pathname.endsWith('.ts')`. -The query-string issue is therefore treated as a bundler-specific test hazard, not as -a POSIX runtime root cause: Vitest/Vite can decorate module URLs, while ordinary POSIX -Node execution does not reproduce that decoration. +The packaged arm remains `worker.cjs` under plain node. The source/built arm now uses +`new URL(import.meta.url).pathname.endsWith('.ts')` so bundler query strings cannot +misclassify source modules as built. ## Runtime inheritance @@ -40,20 +52,58 @@ environment: - `--no-experimental-strip-types` - `--no-strip-types` -Other `NODE_OPTIONS` entries are preserved. This prevents a host-level setting from -turning the fixed Windows launch back into the same generic worker-exit symptom. +Other `NODE_OPTIONS` entries are preserved. Sanitization is deliberately scoped to the +source arm because the packaged `worker.cjs` arm has no native type-stripping dependency +and should not have its inherited options rewritten for an unrelated reason. + +The source graph's erasability is also guarded by the real worker launch: introducing a +non-erasable construct such as a value `enum` or losing a type-only import causes native +Node execution to fail before the expected Win32 dialog error, so POSIX CI catches syntax +or import drift in this graph. ## Related launch paths The CLI source graph still uses tsx because it has a broader runtime dependency graph; that is an intentional separate case. The packaged dialog worker is already CJS and does -not need the source-plane treatment. No other directory-picker worker uses this -Windows-absolute-path-plus-`--import` launch vector. +not need the source-plane treatment. + +`packages/workflow/workflow-worker-thread/src/host.ts:69` also has source/built arm +detection, but its worker boots from a `data:` URL with a proper `file://` href, so it +does not expose the Windows `e:`-scheme failure addressed here. It remains unchanged; +the shared lesson is to use URL pathname when bundler query strings can decorate the URL. + +## Alternatives considered + +**Pass the worker as a `file://` URL instead of a path.** Rejected: tsx's tsconfig-paths +hook mangles `file://` URLs into `\\file:\\` (`ERR_MODULE_NOT_FOUND`); keeping +any tsx involvement leaves a fragile launch. + +**Probe koffi availability and fall back to pure-Node dialogs.** Out of scope: +dshcode pins koffi 3.1.1, which predates the broken 3.1.3/3.1.4 win32-x64 prebuilds, so +the worker's koffi usage is not the failure on this codebase; the worker itself crashed +before koffi ever loaded. + +## Consequences + +- Windows source launches (`pnpm dsh web`) run the worker directly under Node's native + type stripping, removing the `e:` scheme failure caused by the previous loader chain. +- Packaged hosts continue launching the unchanged CJS worker arm, without rewriting + their `NODE_OPTIONS`. +- The source arm remains dependent on the repository Node engines range, an erasable-only + package-local graph, type-only relative imports, and removal of inherited type-stripping + disable flags. +- The Win32 source smoke test now reaches the actual source launch even when Vitest/Vite + decorates module URLs with query strings. ## Verification -- The spawn unit test pins the source worker path as a positional argument and rejects - loader flags. -- The spawn unit test verifies both type-stripping-disabling `NODE_OPTIONS` spellings - are removed while unrelated options are preserved. -- The existing Win32 smoke test continues to cover the real source-plane dialog. +- Package Vitest: **50 passed, 1 skipped** after the coverage regression case is added. +- Coverage gate: the sanitizer's `undefined` branch is explicitly exercised so the + per-file 100% branch threshold is retained. +- Typecheck: passed. +- Lint: passed with 0 warnings and 0 errors. +- `verify-translation-pairing`: passed with the bilingual sidecar record. +- `verify-agent-note-classification`: passed. +- `verify-agent-note-format`: required `Alternatives considered` and `Consequences` are + restored. +- The existing real Win32 smoke continues to exercise the source-plane dialog launch. From b33c991ce07c846278d13ecc62d1940a042aef97 Mon Sep 17 00:00:00 2001 From: Takoyaki White <74053919+takoyakiwhite@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:38:15 +0800 Subject: [PATCH 06/14] docs: sync bilingual win32 worker agent note --- ...19-win32-dialog-worker-source-launch.zh.md | 69 +++++++++++++++---- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md index 5d0d5cc397..6900550502 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md @@ -12,6 +12,10 @@ koffi:源码分支之前使用 `node --import tsx/esm <绝对路径 .ts>`。 loader 后,Windows 绝对路径可能被按 `e:` scheme URL 处理,在 worker 发出第一条 IPC 消息前就因 `ERR_UNSUPPORTED_ESM_URL_SCHEME` 退出。 +CI 还存在第二个测试选择盲区:Vitest/Vite 可能给 `import.meta.url` 添加查询串,因此 +直接使用 `import.meta.url.endsWith('.ts')` 的判断可能在源码测试中错误选择 built 分支。 +这里把它限定为 bundler 测试环境的风险,而不是 POSIX 运行时根因。 + ## 决策 源码 worker 直接由 Node 原生 TypeScript 类型剥离运行: @@ -21,32 +25,69 @@ spawn(process.execPath, [fileURLToPath(new URL('./win32-dialog-worker.ts', impor ``` 仓库 engines 为 `^22.19.0 || >=24.0.0`;该 worker 的依赖图是包内闭合的,worker、 -bindings、logic 都不导入 workspace 包,只使用可擦除的 TypeScript 语法,因此源码 -worker 不需要 tsx hook。打包分支继续由纯 node 启动 `worker.cjs`。 +bindings、logic 都不导入 workspace 包。依赖图只使用可擦除 TypeScript,且使用 +**仅类型的相对导入**;`tsconfig.base.json` 设置了 `verbatimModuleSyntax: false`, +因此 value-position 的类型导入不会自动变成 type-only import,直接原生类型剥离时并不安全。 + +这不是新的运行模式,仓库已有先例:`packages/code-runtime/code-runtime-worker-thread/src/index.ts` +已经让源码 worker 直接由 Node 原生类型剥离加载,并明确要求“仅可擦除语法 + 仅类型相对导入”; +`docs/testing.md#test-subprocess-launch-modes` 也明确允许可擦除 `.ts` 子进程直接由 Node +运行,而不使用 tsx 或根路径映射。 -源码/构建分支判断改用 `new URL(import.meta.url).pathname.endsWith('.ts')`。这里把 -查询串问题明确限定为 bundler 测试环境的风险,而不是 POSIX 运行时的根因: -Vitest/Vite 可能给模块 URL 添加查询串,普通 POSIX Node 执行不会复现这一点。 +打包分支继续由纯 node 启动 `worker.cjs`。源码/构建分支判断改用 +`new URL(import.meta.url).pathname.endsWith('.ts')`,避免 bundler 查询串误判源码模块。 ## 运行时环境继承 -源码 worker 会继承宿主的 `NODE_OPTIONS`。为避免宿主设置重新关闭 Node 原生类型 -剥离,同时兼容支持范围内的两种写法,子进程会移除: +源码 worker 会继承宿主的 `NODE_OPTIONS`。为避免宿主设置重新关闭 Node 原生类型剥离,同时 +兼容支持范围内的两种写法,子进程会移除: - `--no-experimental-strip-types` - `--no-strip-types` -其他 `NODE_OPTIONS` 参数保持不变。这样不会因为宿主环境变量再次把修复后的 -Windows 启动打回同一个笼统的 worker 退出错误。 +其他 `NODE_OPTIONS` 参数保持不变。这个清理只作用于源码分支,因为打包后的 `worker.cjs` +没有原生 TypeScript 类型剥离依赖,不应为无关原因改写其继承环境。 + +源码依赖图的可擦除性也有真实启动保护:如果加入 value `enum` 等不可擦除语法,或者丢失 +类型导入约束,原生 Node worker 会在 POSIX CI 上先于预期的 Win32 对话框错误失败,因此会 +捕获这类源码图漂移。 ## 相关启动路径 -CLI 源码图仍然保留 tsx,因为它的运行时依赖图更大,这是有意的独立场景。打包后的 -dialog worker 已经是 CJS,也不需要这套源码处理。directory-picker 内没有其他采用 -“Windows 绝对路径 + `--import` loader”这一启动向量的 worker。 +CLI 源码图仍然保留 tsx,因为它的运行时依赖图更大,这是有意的独立场景。打包后的 dialog +worker 已经是 CJS,也不需要这套源码处理。 + +`packages/workflow/workflow-worker-thread/src/host.ts:69` 同样存在源码/构建分支判断,但它的 +worker 从带有正确 `file://` href 的 `data:` URL 启动,因此不存在本次 Windows `e:` scheme +失败。该文件无需修改;这里保留说明是为了明确同类 arm-detection 的对称性,以及为什么 +本 PR 不扩展到该包。 + +## Alternatives considered + +**使用 `file://` URL 传入 worker,而不是路径。** 拒绝:tsx 的 tsconfig-paths hook 会把 +`file://` URL 改写成 `\\file:\\`(`ERR_MODULE_NOT_FOUND`);只要继续引入 tsx, +启动方式仍然脆弱。 + +**探测 koffi 可用性并回退到纯 Node 对话框。** 超出范围:dshcode 固定 koffi 3.1.1,早于 +出现问题的 3.1.3/3.1.4 win32-x64 预编译,因此 koffi 并不是本代码库中的失败点;worker +在加载 koffi 之前就已经退出。 + +## Consequences + +- Windows 源码启动(`pnpm dsh web`)直接运行 native type stripping worker,消除原先 + loader 链导致的 `e:` scheme 失败。 +- 打包宿主继续使用不变的 CJS worker,并且不会修改其 `NODE_OPTIONS`。 +- 源码分支现在明确依赖仓库 Node engines、包内闭合的可擦除依赖图、仅类型相对导入,以及 + 清理继承的类型剥离禁用 flag。 +- 即使 Vitest/Vite 给模块 URL 添加查询串,Win32 源码冒烟测试仍会进入真实 source arm。 ## 验证 -- spawn 单测固定源码 worker 以位置参数传入,并拒绝 loader 参数。 -- spawn 单测验证两种关闭类型剥离的 `NODE_OPTIONS` 写法都会被移除,同时保留无关参数。 +- directory-picker-native Vitest:**50 passed, 1 skipped**。 +- coverage:新增 sanitizer `undefined` 分支测试,保持每文件 100% branch threshold。 +- typecheck:通过。 +- lint:0 warnings,0 errors。 +- `verify-translation-pairing`:通过,中英文 Agent Note sidecar 一致。 +- `verify-agent-note-classification`:通过。 +- `verify-agent-note-format`:恢复必需的 `Alternatives considered` 与 `Consequences`。 - 现有 Win32 smoke test 继续覆盖真实源码 worker 的对话框启动。 From b10a8798a0d6186e64a99a00cedfc63cec305a23 Mon Sep 17 00:00:00 2001 From: Takoyaki White <74053919+takoyakiwhite@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:38:27 +0800 Subject: [PATCH 07/14] docs(directory-picker-native): document source worker constraints --- packages/host/directory-picker-native/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/host/directory-picker-native/README.md b/packages/host/directory-picker-native/README.md index 414dee8e0a..10fbb493ff 100644 --- a/packages/host/directory-picker-native/README.md +++ b/packages/host/directory-picker-native/README.md @@ -18,3 +18,4 @@ None; this package neither assembles nor sends a provider request. - **Linux requires desktop tooling** — with neither Zenity nor KDialog installed, `pick` rejects with an actionable error; it does not fall back to a typed-path prompt (the browse backend is that fallback at the composition level). - **Windows has no mechanism fallback** — the child-process picker through packaged koffi is the only native tier, so a COM refusal or dialog crash surfaces the failure. The browse backend remains the fallback at the composition level. +- **Windows source-plane launch depends on native TypeScript stripping** — the source worker is executed directly by Node on the repository engines range (`^22.19.0 || >=24.0.0`), so its package-local dependency graph must remain erasable TypeScript with type-only relative imports. The source child also removes inherited `NODE_OPTIONS` flags that disable native type stripping (`--no-experimental-strip-types` and `--no-strip-types`); unrelated options are preserved. The packaged CJS worker does not have this source-plane dependency. From e28cbaa4245628012705df2d24bd43750114d333 Mon Sep 17 00:00:00 2001 From: Takoyaki White <74053919+takoyakiwhite@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:39:28 +0800 Subject: [PATCH 08/14] docs: update win32 worker verification count --- .../bug-fix/2026-08-19-win32-dialog-worker-source-launch.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md index 526bb7dc4a..aa4b3edd71 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md +++ b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md @@ -97,7 +97,7 @@ before koffi ever loaded. ## Verification -- Package Vitest: **50 passed, 1 skipped** after the coverage regression case is added. +- Package Vitest: **51 passed, 1 skipped** after the coverage and unset-environment cases are added. - Coverage gate: the sanitizer's `undefined` branch is explicitly exercised so the per-file 100% branch threshold is retained. - Typecheck: passed. From 9a0ff35dd4ac2abf7bef09b4d5ec1165478dd7ea Mon Sep 17 00:00:00 2001 From: Takoyaki White <74053919+takoyakiwhite@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:39:42 +0800 Subject: [PATCH 09/14] docs: update bilingual verification count --- .../bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md index 6900550502..e045ba3ae5 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md @@ -83,7 +83,7 @@ worker 从带有正确 `file://` href 的 `data:` URL 启动,因此不存在 ## 验证 -- directory-picker-native Vitest:**50 passed, 1 skipped**。 +- directory-picker-native Vitest:**51 passed, 1 skipped**。 - coverage:新增 sanitizer `undefined` 分支测试,保持每文件 100% branch threshold。 - typecheck:通过。 - lint:0 warnings,0 errors。 From 4b8d29f7e46ada4e5219effc7ef9053c8e158ece Mon Sep 17 00:00:00 2001 From: Takoyaki White <74053919+takoyakiwhite@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:39:53 +0800 Subject: [PATCH 10/14] docs: refresh bilingual note hashes --- .../2026-08-19-win32-dialog-worker-source-launch.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.i18n.yaml index b1649a16fa..250bf27e29 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md -2026-08-19-win32-dialog-worker-source-launch.md: 65cfbdb0370b011782c2188d114f07e45a1ba700 -2026-08-19-win32-dialog-worker-source-launch.zh.md: 5d0d5cc397ac571d04122313a6197e7369ad5d2c +2026-08-19-win32-dialog-worker-source-launch.md: aa4b3edd71fe8c54e7134f8ab403c863b871d9b6 +2026-08-19-win32-dialog-worker-source-launch.zh.md: e045ba3ae5b06ea9992060bf3a7bde64694504d9 From 8d0fe05ed4b4a4ff93f6b2b158bbef5ef11305be Mon Sep 17 00:00:00 2001 From: whitelonng Date: Wed, 19 Aug 2026 16:19:30 +0800 Subject: [PATCH 11/14] docs(directory-picker-native): make the win32 worker note pass the doc gates The Agent Note pair failed three documentation gates. Every paragraph and list item was hard-wrapped, which `verify-md-wrap` rejects (one physical line per paragraph); the `ts` fence ended in `...`, which `doc-typecheck` rejects as TS1109; and the `file://` alternative's path had been escaped to `\\file:\\`, which renders literally inside a code span. The Windows source-plane limitation had also landed in README.md alone, leaving the README pair out of sync with no Chinese counterpart. Unwrap both notes, make the fence compile as a real snippet instead of spending the doc-typecheck opt-out budget, restore the single backslashes, add the Chinese README bullet, and re-record both pairing records. Replace the `## Verification` gate roll-call with the durable mapping from behavior to the test that pins it: gate outcomes belong to CI and the pull request, not to a decision record. Cross-link the CLI source-launch decision the tsx contrast refers to, name `workflow-worker-thread`'s arm detection as the deliberate asymmetry it is, and record why an explicit enabling flag lost to sanitizing NODE_OPTIONS. --- ...in32-dialog-worker-source-launch.i18n.yaml | 4 +- ...08-19-win32-dialog-worker-source-launch.md | 98 ++++++------------- ...19-win32-dialog-worker-source-launch.zh.md | 82 ++++++---------- .../directory-picker-native/README.i18n.yaml | 4 +- .../host/directory-picker-native/README.zh.md | 1 + 5 files changed, 63 insertions(+), 126 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.i18n.yaml index 250bf27e29..b2bfe8d40c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md -2026-08-19-win32-dialog-worker-source-launch.md: aa4b3edd71fe8c54e7134f8ab403c863b871d9b6 -2026-08-19-win32-dialog-worker-source-launch.zh.md: e045ba3ae5b06ea9992060bf3a7bde64694504d9 +2026-08-19-win32-dialog-worker-source-launch.md: 200b8cca7f91bbad4eb9b294ff6e30924b2f0735 +2026-08-19-win32-dialog-worker-source-launch.zh.md: b57b1843585918bb764e059da155d22821aae5a2 diff --git a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md index aa4b3edd71..200b8cca7f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md +++ b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md @@ -6,104 +6,64 @@ English | [中文](2026-08-19-win32-dialog-worker-source-launch.zh.md) ## Problem -On Windows, the source-plane folder dialog worker never started: the Web UI reported -`win32 folder dialog worker exited before reporting a result`. The failure was in the -launch vector, not koffi: the source arm used `node --import tsx/esm `. -With a loader registered through `--import`, a Windows absolute path can be interpreted -as an `e:` scheme URL and rejected with `ERR_UNSUPPORTED_ESM_URL_SCHEME` before the worker -posts its first IPC message. - -CI also had a test-selection blind spot: Vitest/Vite may append a query string to -`import.meta.url`, so a raw `import.meta.url.endsWith('.ts')` check can select the built -arm during source-plane tests. This is a bundler-specific test hazard, not a POSIX runtime -root cause. +On Windows, the source-plane folder dialog worker never started: the Web UI reported `win32 folder dialog worker exited before reporting a result`. The failure was in the launch vector, not koffi: the source arm used `node --import tsx/esm `. With a loader registered through `--import`, a Windows absolute path can be interpreted as an `e:` scheme URL and rejected with `ERR_UNSUPPORTED_ESM_URL_SCHEME` before the worker posts its first IPC message. + +The arm choice also read the raw `import.meta.url`. Vitest and Vite may decorate a module URL with a query string, and a decorated URL failed that `endsWith('.ts')` test, so a source-plane test could select the built arm. That is a bundler-specific test hazard, not a POSIX runtime cause of the Windows failure. ## Decision Run the source worker directly under Node's native type stripping: ```ts -spawn(process.execPath, [fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], ...) +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +spawn(process.execPath, [fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], { + stdio: ['ignore', 'inherit', 'inherit', 'ipc'], +}) ``` -The repository requires `^22.19.0 || >=24.0.0`, and this worker dependency graph is -package-local: the worker, bindings, and logic modules import no workspace packages. -The graph is erasable-only **with type-only relative imports**; `tsconfig.base.json` -uses `verbatimModuleSyntax: false`, so value-position type imports are not automatically -rewritten into type-only imports and would be unsafe for direct native stripping. +The repository requires `^22.19.0 || >=24.0.0`, and this worker dependency graph is package-local: the worker, bindings, and logic modules import no workspace packages, so no tsconfig `paths` projection is needed. The graph is erasable-only **with type-only relative imports**; `tsconfig.base.json` sets `verbatimModuleSyntax: false`, so TypeScript does not force a value-position type import into a type-only one, and such an import would fail under direct native stripping. -This is backed by existing repository precedent rather than a novel launch mode: -`packages/code-runtime/code-runtime-worker-thread/src/index.ts` already loads its -source worker directly under native type stripping and requires an erasable-only graph -with type-only relative imports. `docs/testing.md#test-subprocess-launch-modes` also -explicitly permits erasable `.ts` subprocesses to run directly with Node without tsx or -the root paths map. +`packages/code-runtime/code-runtime-worker-thread/src/index.ts` already loads its source worker this way under the same two preconditions, and `docs/testing.md#test-subprocess-launch-modes` permits erasable `.ts` subprocesses to run directly with Node without tsx or the root paths map. -The packaged arm remains `worker.cjs` under plain node. The source/built arm now uses -`new URL(import.meta.url).pathname.endsWith('.ts')` so bundler query strings cannot -misclassify source modules as built. +The packaged arm remains `worker.cjs` under plain node. Both arms choose from `new URL(import.meta.url).pathname.endsWith('.ts')`, so a query string on the module URL cannot misclassify a source module as built. ## Runtime inheritance -A source worker can inherit `NODE_OPTIONS` from the host. Both spellings used across -the supported Node range that disable native type stripping are removed from the child -environment: +A source worker inherits `NODE_OPTIONS` from the host, and either spelling that disables native type stripping across the supported Node range is removed from the child environment: - `--no-experimental-strip-types` - `--no-strip-types` -Other `NODE_OPTIONS` entries are preserved. Sanitization is deliberately scoped to the -source arm because the packaged `worker.cjs` arm has no native type-stripping dependency -and should not have its inherited options rewritten for an unrelated reason. +Every other `NODE_OPTIONS` entry is preserved, and an options string that carried only disable flags leaves the variable unset in the child. Sanitization is scoped to the source arm: the packaged `worker.cjs` arm has no native type-stripping dependency, so its inherited options are passed through untouched. -The source graph's erasability is also guarded by the real worker launch: introducing a -non-erasable construct such as a value `enum` or losing a type-only import causes native -Node execution to fail before the expected Win32 dialog error, so POSIX CI catches syntax -or import drift in this graph. +The two preconditions are enforced by the real worker launch rather than by a static gate. A non-erasable construct such as a value `enum`, or a type-only import degraded to a value import, makes Node reject the entry before the worker reports, which surfaces as a worker-exit rejection instead of the expected Win32 dialog error. ## Related launch paths -The CLI source graph still uses tsx because it has a broader runtime dependency graph; -that is an intentional separate case. The packaged dialog worker is already CJS and does -not need the source-plane treatment. +The `dsh` CLI source launch keeps the tsx ESM hook because its graph needs a transform mode Node no longer ships, per [the source-launch decision](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md); that constraint is about the CLI graph, not about native stripping being unavailable in the engines range. -`packages/workflow/workflow-worker-thread/src/host.ts:69` also has source/built arm -detection, but its worker boots from a `data:` URL with a proper `file://` href, so it -does not expose the Windows `e:`-scheme failure addressed here. It remains unchanged; -the shared lesson is to use URL pathname when bundler query strings can decorate the URL. +`packages/workflow/workflow-worker-thread/src/host.ts` selects its own source/built arm from the raw `import.meta.url`, but it boots the worker from a `data:` URL carrying a proper `file://` href, so the Windows `e:`-scheme failure cannot reach that launch. ## Alternatives considered -**Pass the worker as a `file://` URL instead of a path.** Rejected: tsx's tsconfig-paths -hook mangles `file://` URLs into `\\file:\\` (`ERR_MODULE_NOT_FOUND`); keeping -any tsx involvement leaves a fragile launch. +**Pass the worker as a `file://` URL instead of a path.** Rejected: tsx's tsconfig-paths hook mangles `file://` URLs into `\file:\` (`ERR_MODULE_NOT_FOUND`); keeping any tsx involvement leaves a fragile launch. + +**Probe koffi availability and fall back to pure-Node dialogs.** Out of scope: dshcode pins koffi 3.1.1, which predates the broken 3.1.3/3.1.4 win32-x64 prebuilds, so the worker's koffi usage is not the failure on this codebase; the worker itself crashed before koffi ever loaded. -**Probe koffi availability and fall back to pure-Node dialogs.** Out of scope: -dshcode pins koffi 3.1.1, which predates the broken 3.1.3/3.1.4 win32-x64 prebuilds, so -the worker's koffi usage is not the failure on this codebase; the worker itself crashed -before koffi ever loaded. +**Pass an explicit enabling flag to the child instead of sanitizing `NODE_OPTIONS`.** Rejected: Node already renamed the negation of this feature once (`--no-experimental-strip-types`, then `--no-strip-types`), so a hardcoded enabling flag couples the launch to a Node line; removing both known disable spellings needs no such pin and leaves every unrelated host option intact. ## Consequences -- Windows source launches (`pnpm dsh web`) run the worker directly under Node's native - type stripping, removing the `e:` scheme failure caused by the previous loader chain. -- Packaged hosts continue launching the unchanged CJS worker arm, without rewriting - their `NODE_OPTIONS`. -- The source arm remains dependent on the repository Node engines range, an erasable-only - package-local graph, type-only relative imports, and removal of inherited type-stripping - disable flags. -- The Win32 source smoke test now reaches the actual source launch even when Vitest/Vite - decorates module URLs with query strings. +- Windows source launches (`pnpm dsh web`) run the worker directly under Node's native type stripping, so the `e:` scheme failure of the loader chain is gone. +- Packaged hosts keep the unchanged CJS worker arm and an untouched `NODE_OPTIONS`. +- The source arm depends on the engines range, a package-local erasable-only graph, type-only relative imports, and removal of inherited type-stripping disable flags; `packages/host/directory-picker-native/README.md` records that for consumers. +- The Win32 smoke reaches the real source launch even where a module runner decorates URLs with query strings. ## Verification -- Package Vitest: **51 passed, 1 skipped** after the coverage and unset-environment cases are added. -- Coverage gate: the sanitizer's `undefined` branch is explicitly exercised so the - per-file 100% branch threshold is retained. -- Typecheck: passed. -- Lint: passed with 0 warnings and 0 errors. -- `verify-translation-pairing`: passed with the bilingual sidecar record. -- `verify-agent-note-classification`: passed. -- `verify-agent-note-format`: required `Alternatives considered` and `Consequences` are - restored. -- The existing real Win32 smoke continues to exercise the source-plane dialog launch. +- `packages/host/directory-picker-native/tests/win32-dialog-host.spec.ts` pins the source launch: `process.execPath` runs the worker path as the sole positional argument, with no loader flag. +- The same suite pins all three `NODE_OPTIONS` inputs: a mixed string keeps its unrelated entries, a string of only disable flags leaves the variable unset, and an unset variable stays unset without the parent being mutated. +- `tests/win32-dialog.spec.ts` launches the real source worker on POSIX, which is what catches a non-erasable construct or a lost type-only import in this graph. +- On win32 the same suite opens and abort-closes a real dialog through the source arm; `tests/built-worker.e2e.ts` owns the packaged `worker.cjs` arm this decision leaves unchanged. diff --git a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md index e045ba3ae5..b57b184358 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md @@ -6,88 +6,64 @@ Status: implemented ## 问题 -Windows 上源码层面的文件夹对话框 worker 无法启动:Web UI 只能看到 -`win32 folder dialog worker exited before reporting a result`。问题出在启动向量而非 -koffi:源码分支之前使用 `node --import tsx/esm <绝对路径 .ts>`。注册 `--import` -loader 后,Windows 绝对路径可能被按 `e:` scheme URL 处理,在 worker 发出第一条 IPC -消息前就因 `ERR_UNSUPPORTED_ESM_URL_SCHEME` 退出。 +Windows 上源码层面的文件夹对话框 worker 从未启动成功:Web UI 只报出 `win32 folder dialog worker exited before reporting a result`。故障出在启动向量而非 koffi:源码分支使用 `node --import tsx/esm <绝对路径 .ts>`。通过 `--import` 注册 loader 后,Windows 绝对路径可能被当作 `e:` scheme URL 解析并以 `ERR_UNSUPPORTED_ESM_URL_SCHEME` 拒绝,此时 worker 还没发出第一条 IPC 消息。 -CI 还存在第二个测试选择盲区:Vitest/Vite 可能给 `import.meta.url` 添加查询串,因此 -直接使用 `import.meta.url.endsWith('.ts')` 的判断可能在源码测试中错误选择 built 分支。 -这里把它限定为 bundler 测试环境的风险,而不是 POSIX 运行时根因。 +分支判断此前读的是裸 `import.meta.url`。Vitest 与 Vite 可能给模块 URL 附加查询串,而带查询串的 URL 通不过当时的 `endsWith('.ts')` 判断,于是源码层面的测试会选中 built 分支。这属于 bundler 测试环境的风险,不是 Windows 故障在 POSIX 运行时的成因。 ## 决策 -源码 worker 直接由 Node 原生 TypeScript 类型剥离运行: +源码 worker 直接由 Node 原生类型剥离运行: ```ts -spawn(process.execPath, [fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], ...) +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +spawn(process.execPath, [fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], { + stdio: ['ignore', 'inherit', 'inherit', 'ipc'], +}) ``` -仓库 engines 为 `^22.19.0 || >=24.0.0`;该 worker 的依赖图是包内闭合的,worker、 -bindings、logic 都不导入 workspace 包。依赖图只使用可擦除 TypeScript,且使用 -**仅类型的相对导入**;`tsconfig.base.json` 设置了 `verbatimModuleSyntax: false`, -因此 value-position 的类型导入不会自动变成 type-only import,直接原生类型剥离时并不安全。 +仓库 engines 要求 `^22.19.0 || >=24.0.0`,且该 worker 的依赖图是包内闭合的:worker、bindings、logic 三个模块都不导入 workspace 包,因此不需要 tsconfig `paths` 投射。依赖图只使用可擦除语法,且**相对导入全部为仅类型导入**;`tsconfig.base.json` 设置了 `verbatimModuleSyntax: false`,TypeScript 不会把值位置的类型导入强制改写为仅类型导入,而这种导入在直接原生剥离下会失败。 -这不是新的运行模式,仓库已有先例:`packages/code-runtime/code-runtime-worker-thread/src/index.ts` -已经让源码 worker 直接由 Node 原生类型剥离加载,并明确要求“仅可擦除语法 + 仅类型相对导入”; -`docs/testing.md#test-subprocess-launch-modes` 也明确允许可擦除 `.ts` 子进程直接由 Node -运行,而不使用 tsx 或根路径映射。 +`packages/code-runtime/code-runtime-worker-thread/src/index.ts` 已经在同样这两个前提下以这种方式加载它的源码 worker,`docs/testing.md#test-subprocess-launch-modes` 也允许可擦除的 `.ts` 子进程直接由 Node 运行,不经 tsx 或根路径映射。 -打包分支继续由纯 node 启动 `worker.cjs`。源码/构建分支判断改用 -`new URL(import.meta.url).pathname.endsWith('.ts')`,避免 bundler 查询串误判源码模块。 +打包分支继续由纯 node 启动 `worker.cjs`。两个分支都由 `new URL(import.meta.url).pathname.endsWith('.ts')` 选择,模块 URL 上的查询串无法把源码模块误判为构建产物。 ## 运行时环境继承 -源码 worker 会继承宿主的 `NODE_OPTIONS`。为避免宿主设置重新关闭 Node 原生类型剥离,同时 -兼容支持范围内的两种写法,子进程会移除: +源码 worker 会继承宿主的 `NODE_OPTIONS`,支持的 Node 范围内两种关闭原生类型剥离的写法都会从子进程环境中移除: - `--no-experimental-strip-types` - `--no-strip-types` -其他 `NODE_OPTIONS` 参数保持不变。这个清理只作用于源码分支,因为打包后的 `worker.cjs` -没有原生 TypeScript 类型剥离依赖,不应为无关原因改写其继承环境。 +其余 `NODE_OPTIONS` 条目全部保留;若整串只有这些禁用 flag,子进程中该变量为未设置。清理只作用于源码分支:打包后的 `worker.cjs` 分支没有原生类型剥离依赖,其继承的选项原样透传。 -源码依赖图的可擦除性也有真实启动保护:如果加入 value `enum` 等不可擦除语法,或者丢失 -类型导入约束,原生 Node worker 会在 POSIX CI 上先于预期的 Win32 对话框错误失败,因此会 -捕获这类源码图漂移。 +这两个前提由真实 worker 启动而非静态门禁保证。加入 value `enum` 这类不可擦除语法,或把仅类型导入退化为值导入,Node 会在 worker 上报之前拒绝入口,表现为 worker 退出类拒绝,而不是预期中的 Win32 对话框错误。 ## 相关启动路径 -CLI 源码图仍然保留 tsx,因为它的运行时依赖图更大,这是有意的独立场景。打包后的 dialog -worker 已经是 CJS,也不需要这套源码处理。 +`dsh` CLI 的源码启动保留 tsx ESM hook,因为它的源码图需要 Node 已不再提供的 transform 模式,见[源码启动决策](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md);那条约束针对的是 CLI 源码图,而不是说 engines 范围内没有原生剥离。 -`packages/workflow/workflow-worker-thread/src/host.ts:69` 同样存在源码/构建分支判断,但它的 -worker 从带有正确 `file://` href 的 `data:` URL 启动,因此不存在本次 Windows `e:` scheme -失败。该文件无需修改;这里保留说明是为了明确同类 arm-detection 的对称性,以及为什么 -本 PR 不扩展到该包。 +`packages/workflow/workflow-worker-thread/src/host.ts` 同样从裸 `import.meta.url` 选择源码/构建分支,但它的 worker 从携带正确 `file://` href 的 `data:` URL 启动,Windows `e:` scheme 故障触及不到那条启动路径。 ## Alternatives considered -**使用 `file://` URL 传入 worker,而不是路径。** 拒绝:tsx 的 tsconfig-paths hook 会把 -`file://` URL 改写成 `\\file:\\`(`ERR_MODULE_NOT_FOUND`);只要继续引入 tsx, -启动方式仍然脆弱。 +**把 worker 作为 `file://` URL 而不是路径传入。** 拒绝:tsx 的 tsconfig-paths hook 会把 `file://` URL 改写成 `\file:\`(`ERR_MODULE_NOT_FOUND`);只要还牵扯 tsx,启动就是脆弱的。 + +**探测 koffi 可用性并回退到纯 Node 对话框。** 超出范围:dshcode 固定 koffi 3.1.1,早于损坏的 3.1.3/3.1.4 win32-x64 预编译,因此 worker 对 koffi 的用法并非本代码库的故障点;worker 在 koffi 加载之前就已崩溃。 -**探测 koffi 可用性并回退到纯 Node 对话框。** 超出范围:dshcode 固定 koffi 3.1.1,早于 -出现问题的 3.1.3/3.1.4 win32-x64 预编译,因此 koffi 并不是本代码库中的失败点;worker -在加载 koffi 之前就已经退出。 +**给子进程显式传入开启 flag,而不是清理 `NODE_OPTIONS`。** 拒绝:Node 已经把该特性的否定写法改过一次(先是 `--no-experimental-strip-types`,后为 `--no-strip-types`),硬编码开启 flag 会把启动绑定到某条 Node 线;移除两种已知的禁用写法不需要这种绑定,也不动宿主的任何无关选项。 ## Consequences -- Windows 源码启动(`pnpm dsh web`)直接运行 native type stripping worker,消除原先 - loader 链导致的 `e:` scheme 失败。 -- 打包宿主继续使用不变的 CJS worker,并且不会修改其 `NODE_OPTIONS`。 -- 源码分支现在明确依赖仓库 Node engines、包内闭合的可擦除依赖图、仅类型相对导入,以及 - 清理继承的类型剥离禁用 flag。 -- 即使 Vitest/Vite 给模块 URL 添加查询串,Win32 源码冒烟测试仍会进入真实 source arm。 +- Windows 源码启动(`pnpm dsh web`)直接由 Node 原生类型剥离运行 worker,loader 链带来的 `e:` scheme 故障消失。 +- 打包宿主保持不变的 CJS worker 分支,`NODE_OPTIONS` 不被改写。 +- 源码分支依赖 engines 范围、包内闭合且只含可擦除语法的依赖图、仅类型的相对导入,以及移除继承的类型剥离禁用 flag;`packages/host/directory-picker-native/README.md` 为使用者记录了这些前提。 +- 即使模块运行器给 URL 附加查询串,Win32 冒烟测试也能进入真实的源码启动。 ## 验证 -- directory-picker-native Vitest:**51 passed, 1 skipped**。 -- coverage:新增 sanitizer `undefined` 分支测试,保持每文件 100% branch threshold。 -- typecheck:通过。 -- lint:0 warnings,0 errors。 -- `verify-translation-pairing`:通过,中英文 Agent Note sidecar 一致。 -- `verify-agent-note-classification`:通过。 -- `verify-agent-note-format`:恢复必需的 `Alternatives considered` 与 `Consequences`。 -- 现有 Win32 smoke test 继续覆盖真实源码 worker 的对话框启动。 +- `packages/host/directory-picker-native/tests/win32-dialog-host.spec.ts` 钉住源码启动:由 `process.execPath` 以唯一位置参数运行 worker 路径,不带任何 loader flag。 +- 同一套件钉住 `NODE_OPTIONS` 的三种输入:混合串保留无关条目、只含禁用 flag 的串使该变量为未设置、未设置时保持未设置且父进程不被修改。 +- `tests/win32-dialog.spec.ts` 在 POSIX 上启动真实源码 worker,这正是能抓住依赖图中不可擦除语法或丢失仅类型导入的地方。 +- 在 win32 上,同一套件通过源码分支真实打开并中止关闭对话框;`tests/built-worker.e2e.ts` 负责本决策未改动的打包 `worker.cjs` 分支。 diff --git a/packages/host/directory-picker-native/README.i18n.yaml b/packages/host/directory-picker-native/README.i18n.yaml index dc1cf3adab..689dc7461a 100644 --- a/packages/host/directory-picker-native/README.i18n.yaml +++ b/packages/host/directory-picker-native/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker-native/README.md -README.md: 414dee8e0a064277dfd153196c17c780cb31bcb0 -README.zh.md: 3463f149d175ae9c2b95cc5eb25fe9b3762df902 +README.md: 10fbb493ff296587db44244565f38cbd155f419f +README.zh.md: 8f4f0a1b6cd2c9b9d542a973ae632b861f33cc22 diff --git a/packages/host/directory-picker-native/README.zh.md b/packages/host/directory-picker-native/README.zh.md index 3463f149d1..8f4f0a1b6c 100644 --- a/packages/host/directory-picker-native/README.zh.md +++ b/packages/host/directory-picker-native/README.zh.md @@ -18,3 +18,4 @@ - **Linux 依赖桌面工具**——Zenity 与 KDialog 均未安装时,`pick` 以包含解决建议的错误拒绝;它不会回退为手输路径提示(组合层面的回退是 browse 后端)。 - **Windows 没有机制级回退**——通过打包依赖 koffi 运行的子进程选择器是唯一原生层级,因此 COM 拒绝或对话框崩溃会直接上报失败。组合层面的回退仍是 browse 后端。 +- **Windows 源码层启动依赖原生 TypeScript 类型剥离**——源码 worker 在仓库 engines 范围(`^22.19.0 || >=24.0.0`)内由 Node 直接执行,因此其包内闭合的依赖图必须保持可擦除 TypeScript 且相对导入全部为仅类型导入。源码子进程还会移除继承而来的关闭原生类型剥离的 `NODE_OPTIONS` flag(`--no-experimental-strip-types` 与 `--no-strip-types`);无关选项原样保留。打包后的 CJS worker 没有这项源码层依赖。 From e020158127f30350cf08f54d7b226c522597d6df Mon Sep 17 00:00:00 2001 From: whitelonng Date: Wed, 19 Aug 2026 16:19:41 +0800 Subject: [PATCH 12/14] test(directory-picker-native): pin the source-launch command, not the args twice `toEqual([workerPath])` already proves no loader flag is present, so the following `not.toContain('--import')` restated it. Assert `process.execPath` instead: the claim the source arm makes is that plain node runs the worker, and nothing covered the command before. --- .../directory-picker-native/tests/win32-dialog-host.spec.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/host/directory-picker-native/tests/win32-dialog-host.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog-host.spec.ts index b1fb6b5701..db5945be79 100644 --- a/packages/host/directory-picker-native/tests/win32-dialog-host.spec.ts +++ b/packages/host/directory-picker-native/tests/win32-dialog-host.spec.ts @@ -72,8 +72,7 @@ describe('spawnDialogWorker', () => { spawnDialogWorker({ title: 'Source-plane guard' }) expect(spawnMock).toHaveBeenCalledOnce() - const args = spawnMock.mock.calls[0]?.[1] - expect(args).toEqual([fileURLToPath(new URL('../src/win32-dialog-worker.ts', import.meta.url))]) - expect(args).not.toContain('--import') + expect(spawnMock.mock.calls[0]?.[0]).toBe(process.execPath) + expect(spawnMock.mock.calls[0]?.[1]).toEqual([fileURLToPath(new URL('../src/win32-dialog-worker.ts', import.meta.url))]) }) }) From b2f6110aed7c5380d2c963b9133c9a74e00371d8 Mon Sep 17 00:00:00 2001 From: whitelonng Date: Wed, 19 Aug 2026 16:48:48 +0800 Subject: [PATCH 13/14] docs: correct the source-plane preconditions two reviews found wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent reviews of the previous docs commit found the Chinese note and README asserting a constraint the code violates: "相对导入全部为仅类型 导入" says every relative import is type-only, but the worker imports `loadWin32DialogBindings` and `runFolderDialog` as values. The English "with type-only relative imports" carries the same ambiguity, inherited from `code-runtime-worker-thread`, whose own worker also has a value import. State the rule the code actually depends on: every relative import that names a type must be marked, with `import type` or the inline `type` modifier. Measured, not inferred — `tsc` with this repository's settings exits 0 on an unmarked type import while `node` fails it at load with "does not provide an export named", so neither typecheck nor the bundle can catch it and marking is mandatory without any rule demanding it. Record the two omissions the reviews also caught. `sandbox-local`'s windows-acl runner still builds the `--import tsx/esm` plus absolute path vector on the same source-plane condition, which is the actual sibling instance of this bug and needs its own change; the workflow host, which the section named instead, boots from a `data:` URL and cannot hit it. And the sanitizer preserves an inherited `--import`, which puts the loader chain back in front of the worker path, so the note and README bound the promise instead of implying the child is safe from host options. Also: root AGENTS.md said Node's native TypeScript modes are unavailable across the engines range, which two packages now contradict by loading source workers under strip mode; the CLI needs the transform mode Node dropped, so name that. Fix the verification bullets that misattributed the parent-non-mutation assertion and described the wrong failure message, cite the launch-modes policy and the package README as checkable links, drop the unverifiable koffi prebuild claim for what the lockfile shows, restore the `E:\...` example, rename the NODE_OPTIONS section for its subject, move the erasability guard beside the preconditions it guards, and use 源码面 and Chinese headings per the repository's own conventions. --- ...in32-dialog-worker-source-launch.i18n.yaml | 4 +- ...08-19-win32-dialog-worker-source-launch.md | 39 +++++++++-------- ...19-win32-dialog-worker-source-launch.zh.md | 43 +++++++++++-------- AGENTS.md | 2 +- .../directory-picker-native/README.i18n.yaml | 4 +- .../host/directory-picker-native/README.md | 2 +- .../host/directory-picker-native/README.zh.md | 2 +- .../src/win32-dialog-host.ts | 6 ++- 8 files changed, 57 insertions(+), 45 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.i18n.yaml index b2bfe8d40c..9cf743012c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md -2026-08-19-win32-dialog-worker-source-launch.md: 200b8cca7f91bbad4eb9b294ff6e30924b2f0735 -2026-08-19-win32-dialog-worker-source-launch.zh.md: b57b1843585918bb764e059da155d22821aae5a2 +2026-08-19-win32-dialog-worker-source-launch.md: a61e2cb8b8c2a7bf95098a7aacc18a7c8973e362 +2026-08-19-win32-dialog-worker-source-launch.zh.md: 0f99fa039a2dfd9a30c16b9969dace8aef54c909 diff --git a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md index 200b8cca7f..a61e2cb8b8 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md +++ b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md @@ -6,9 +6,9 @@ English | [中文](2026-08-19-win32-dialog-worker-source-launch.zh.md) ## Problem -On Windows, the source-plane folder dialog worker never started: the Web UI reported `win32 folder dialog worker exited before reporting a result`. The failure was in the launch vector, not koffi: the source arm used `node --import tsx/esm `. With a loader registered through `--import`, a Windows absolute path can be interpreted as an `e:` scheme URL and rejected with `ERR_UNSUPPORTED_ESM_URL_SCHEME` before the worker posts its first IPC message. +On Windows, the source-plane folder dialog worker never started: the Web UI reported `win32 folder dialog worker exited before reporting a result`. The failure was in the launch vector, not koffi: the source arm ran `node --import tsx/esm `. With a loader registered through `--import`, an absolute path such as `E:\dsh\packages\host\directory-picker-native\src\win32-dialog-worker.ts` can be read as an `e:` scheme URL and rejected with `ERR_UNSUPPORTED_ESM_URL_SCHEME`, before the worker posts its first IPC message. -The arm choice also read the raw `import.meta.url`. Vitest and Vite may decorate a module URL with a query string, and a decorated URL failed that `endsWith('.ts')` test, so a source-plane test could select the built arm. That is a bundler-specific test hazard, not a POSIX runtime cause of the Windows failure. +A raw `import.meta.url.endsWith('.ts')` check also decided which arm to launch. Vitest and Vite may decorate a module URL with a query string, and a decorated URL fails that suffix test, so a source-plane test could exercise the built arm — a bundler-specific test hazard rather than a cause of the Windows failure. ## Decision @@ -18,52 +18,57 @@ Run the source worker directly under Node's native type stripping: import { spawn } from 'node:child_process' import { fileURLToPath } from 'node:url' -spawn(process.execPath, [fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], { - stdio: ['ignore', 'inherit', 'inherit', 'ipc'], -}) +declare const env: NodeJS.ProcessEnv +spawn(process.execPath, [fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], { env, windowsHide: true }) ``` -The repository requires `^22.19.0 || >=24.0.0`, and this worker dependency graph is package-local: the worker, bindings, and logic modules import no workspace packages, so no tsconfig `paths` projection is needed. The graph is erasable-only **with type-only relative imports**; `tsconfig.base.json` sets `verbatimModuleSyntax: false`, so TypeScript does not force a value-position type import into a type-only one, and such an import would fail under direct native stripping. +The repository requires `^22.19.0 || >=24.0.0`, and this worker dependency graph is package-local: the worker, bindings, and logic modules import no workspace packages, so no tsconfig `paths` projection is needed. -`packages/code-runtime/code-runtime-worker-thread/src/index.ts` already loads its source worker this way under the same two preconditions, and `docs/testing.md#test-subprocess-launch-modes` permits erasable `.ts` subprocesses to run directly with Node without tsx or the root paths map. +Every relative import that names a type is marked, with `import type` or the inline `type` modifier. `tsconfig.base.json` sets `verbatimModuleSyntax: false`, so an unmarked type import is elided at build time and passes both `typecheck` and the bundle, while Node strip mode keeps the specifier and fails at load with `does not provide an export named`. Marking is mandatory here even though no compiler or lint rule demands it. + +`packages/code-runtime/code-runtime-worker-thread/src/index.ts` already loads its source worker this way under the same two preconditions, and [the test-subprocess launch modes](../../../../docs/testing.md#test-subprocess-launch-modes) permit erasable `.ts` subprocesses to run directly with Node without tsx or the root paths map. The packaged arm remains `worker.cjs` under plain node. Both arms choose from `new URL(import.meta.url).pathname.endsWith('.ts')`, so a query string on the module URL cannot misclassify a source module as built. -## Runtime inheritance +Neither precondition has a static gate; the real worker launch enforces both. A value `enum`, or a type import left unmarked, makes Node reject the entry before the worker posts, which surfaces as the worker-exit rejection instead of the expected Win32 dialog error. + +## Inherited NODE_OPTIONS A source worker inherits `NODE_OPTIONS` from the host, and either spelling that disables native type stripping across the supported Node range is removed from the child environment: - `--no-experimental-strip-types` - `--no-strip-types` -Every other `NODE_OPTIONS` entry is preserved, and an options string that carried only disable flags leaves the variable unset in the child. Sanitization is scoped to the source arm: the packaged `worker.cjs` arm has no native type-stripping dependency, so its inherited options are passed through untouched. +Every other entry is preserved, and an options string that carried only disable flags leaves the variable unset in the child. Sanitization is scoped to the source arm: the packaged `worker.cjs` arm has no native type-stripping dependency, so its inherited options pass through untouched. -The two preconditions are enforced by the real worker launch rather than by a static gate. A non-erasable construct such as a value `enum`, or a type-only import degraded to a value import, makes Node reject the entry before the worker reports, which surfaces as a worker-exit rejection instead of the expected Win32 dialog error. +An inherited `--import` is preserved like any other entry, so a host that registers a loader process-wide puts the `e:` scheme hazard back in front of the worker path. This launch cannot tell an instrumentation hook from a TypeScript one, so that case stays the host's to avoid. ## Related launch paths The `dsh` CLI source launch keeps the tsx ESM hook because its graph needs a transform mode Node no longer ships, per [the source-launch decision](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md); that constraint is about the CLI graph, not about native stripping being unavailable in the engines range. -`packages/workflow/workflow-worker-thread/src/host.ts` selects its own source/built arm from the raw `import.meta.url`, but it boots the worker from a `data:` URL carrying a proper `file://` href, so the Windows `e:`-scheme failure cannot reach that launch. +`packages/sandbox/sandbox-local/src/index.ts` still builds this vector for the windows-acl runner's source arm, and that graph is package-local and erasable too, so the same launch applies there. It is a separate change: it also rewrites the assertion in `packages/sandbox/sandbox-local/tests/local.spec.ts` that pins the `--import tsx/esm` prefix. + +`packages/workflow/workflow-worker-thread/src/host.ts` selects its own source/built arm from the raw `import.meta.url`, but it boots the worker from a `data:` URL carrying a proper `file://` href, so the `e:` scheme failure cannot reach that launch. ## Alternatives considered **Pass the worker as a `file://` URL instead of a path.** Rejected: tsx's tsconfig-paths hook mangles `file://` URLs into `\file:\` (`ERR_MODULE_NOT_FOUND`); keeping any tsx involvement leaves a fragile launch. -**Probe koffi availability and fall back to pure-Node dialogs.** Out of scope: dshcode pins koffi 3.1.1, which predates the broken 3.1.3/3.1.4 win32-x64 prebuilds, so the worker's koffi usage is not the failure on this codebase; the worker itself crashed before koffi ever loaded. +**Probe koffi availability and fall back to pure-Node dialogs.** Out of scope: the lockfile resolves koffi to 3.1.1, and the worker crashed before koffi ever loaded, so koffi is not the failure on this codebase. -**Pass an explicit enabling flag to the child instead of sanitizing `NODE_OPTIONS`.** Rejected: Node already renamed the negation of this feature once (`--no-experimental-strip-types`, then `--no-strip-types`), so a hardcoded enabling flag couples the launch to a Node line; removing both known disable spellings needs no such pin and leaves every unrelated host option intact. +**Pass an explicit enabling flag to the child instead of sanitizing `NODE_OPTIONS`.** Rejected: Node has already renamed this feature's negation once (`--no-experimental-strip-types`, then `--no-strip-types`), so a hardcoded enabling flag couples the launch to a Node line, while removing both known disable spellings works across the engines range. ## Consequences -- Windows source launches (`pnpm dsh web`) run the worker directly under Node's native type stripping, so the `e:` scheme failure of the loader chain is gone. +- Windows source launches (`pnpm dsh web`) run the worker directly under Node's native type stripping, so no loader chain can read the worker path as an `e:` scheme URL. - Packaged hosts keep the unchanged CJS worker arm and an untouched `NODE_OPTIONS`. -- The source arm depends on the engines range, a package-local erasable-only graph, type-only relative imports, and removal of inherited type-stripping disable flags; `packages/host/directory-picker-native/README.md` records that for consumers. +- The source arm depends on the engines range, a package-local erasable-only graph, marked type imports, and removal of inherited type-stripping disable flags; [the package README](../../../../packages/host/directory-picker-native/README.md) states those preconditions for consumers. - The Win32 smoke reaches the real source launch even where a module runner decorates URLs with query strings. ## Verification - `packages/host/directory-picker-native/tests/win32-dialog-host.spec.ts` pins the source launch: `process.execPath` runs the worker path as the sole positional argument, with no loader flag. -- The same suite pins all three `NODE_OPTIONS` inputs: a mixed string keeps its unrelated entries, a string of only disable flags leaves the variable unset, and an unset variable stays unset without the parent being mutated. -- `tests/win32-dialog.spec.ts` launches the real source worker on POSIX, which is what catches a non-erasable construct or a lost type-only import in this graph. +- The same suite covers the three `NODE_OPTIONS` cases — a mixed string keeps its unrelated entries and leaves the parent untouched, a string of only disable flags leaves the variable unset, and an unset variable stays unset. +- `tests/win32-dialog.spec.ts` launches the real source worker on POSIX. A non-erasable construct or an unmarked type import makes that launch exit before reporting, so the test fails on the worker-exit rejection instead of the expected `win32 folder dialog failed`. - On win32 the same suite opens and abort-closes a real dialog through the source arm; `tests/built-worker.e2e.ts` owns the packaged `worker.cjs` arm this decision leaves unchanged. diff --git a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md index b57b184358..0f99fa039a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md @@ -6,9 +6,9 @@ Status: implemented ## 问题 -Windows 上源码层面的文件夹对话框 worker 从未启动成功:Web UI 只报出 `win32 folder dialog worker exited before reporting a result`。故障出在启动向量而非 koffi:源码分支使用 `node --import tsx/esm <绝对路径 .ts>`。通过 `--import` 注册 loader 后,Windows 绝对路径可能被当作 `e:` scheme URL 解析并以 `ERR_UNSUPPORTED_ESM_URL_SCHEME` 拒绝,此时 worker 还没发出第一条 IPC 消息。 +Windows 上源码面的文件夹对话框 worker 从未启动成功:Web UI 只报出 `win32 folder dialog worker exited before reporting a result`。故障出在启动向量而非 koffi:源码分支运行的是 `node --import tsx/esm <绝对路径 .ts>`。通过 `--import` 注册 loader 后,像 `E:\dsh\packages\host\directory-picker-native\src\win32-dialog-worker.ts` 这样的绝对路径可能被读作 `e:` scheme URL 并以 `ERR_UNSUPPORTED_ESM_URL_SCHEME` 拒绝,此时 worker 还没发出第一条 IPC 消息。 -分支判断此前读的是裸 `import.meta.url`。Vitest 与 Vite 可能给模块 URL 附加查询串,而带查询串的 URL 通不过当时的 `endsWith('.ts')` 判断,于是源码层面的测试会选中 built 分支。这属于 bundler 测试环境的风险,不是 Windows 故障在 POSIX 运行时的成因。 +决定启动哪个分支的判断此前读的是裸 `import.meta.url`。Vitest 与 Vite 可能给模块 URL 附加查询串,带查询串的 URL 通不过这个后缀判断,于是源码面的测试可能跑到 built 分支上——这属于 bundler 测试环境的风险,而不是 Windows 故障的成因。 ## 决策 @@ -18,52 +18,57 @@ Windows 上源码层面的文件夹对话框 worker 从未启动成功:Web UI import { spawn } from 'node:child_process' import { fileURLToPath } from 'node:url' -spawn(process.execPath, [fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], { - stdio: ['ignore', 'inherit', 'inherit', 'ipc'], -}) +declare const env: NodeJS.ProcessEnv +spawn(process.execPath, [fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], { env, windowsHide: true }) ``` -仓库 engines 要求 `^22.19.0 || >=24.0.0`,且该 worker 的依赖图是包内闭合的:worker、bindings、logic 三个模块都不导入 workspace 包,因此不需要 tsconfig `paths` 投射。依赖图只使用可擦除语法,且**相对导入全部为仅类型导入**;`tsconfig.base.json` 设置了 `verbatimModuleSyntax: false`,TypeScript 不会把值位置的类型导入强制改写为仅类型导入,而这种导入在直接原生剥离下会失败。 +仓库 engines 要求 `^22.19.0 || >=24.0.0`,且该 worker 的依赖图是包内闭合的:worker、bindings、logic 三个模块都不导入 workspace 包,因此不需要 tsconfig `paths` 投射。 -`packages/code-runtime/code-runtime-worker-thread/src/index.ts` 已经在同样这两个前提下以这种方式加载它的源码 worker,`docs/testing.md#test-subprocess-launch-modes` 也允许可擦除的 `.ts` 子进程直接由 Node 运行,不经 tsx 或根路径映射。 +凡是命名类型的相对导入都必须标注,用 `import type` 或行内 `type` 修饰符。`tsconfig.base.json` 设置了 `verbatimModuleSyntax: false`,未标注的类型导入会在构建时被消除,`typecheck` 与打包都不会报错,而 Node 剥离模式会保留该导入并在加载时以 `does not provide an export named` 失败。因此即使没有任何编译器或 lint 规则强制,这里也必须标注。 + +`packages/code-runtime/code-runtime-worker-thread/src/index.ts` 已经在同样这两个前提下以这种方式加载它的源码 worker,[测试子进程启动方式](../../../../docs/testing.md#test-subprocess-launch-modes)也允许可擦除的 `.ts` 子进程直接由 Node 运行,不经 tsx 或根路径映射。 打包分支继续由纯 node 启动 `worker.cjs`。两个分支都由 `new URL(import.meta.url).pathname.endsWith('.ts')` 选择,模块 URL 上的查询串无法把源码模块误判为构建产物。 -## 运行时环境继承 +这两个前提都没有静态门禁,由真实 worker 启动来保证。出现 value `enum`,或有类型导入漏了标注,Node 会在 worker 上报之前拒绝入口,表现为 worker 退出类拒绝,而不是预期中的 Win32 对话框错误。 + +## 继承的 NODE_OPTIONS 源码 worker 会继承宿主的 `NODE_OPTIONS`,支持的 Node 范围内两种关闭原生类型剥离的写法都会从子进程环境中移除: - `--no-experimental-strip-types` - `--no-strip-types` -其余 `NODE_OPTIONS` 条目全部保留;若整串只有这些禁用 flag,子进程中该变量为未设置。清理只作用于源码分支:打包后的 `worker.cjs` 分支没有原生类型剥离依赖,其继承的选项原样透传。 +其余条目全部保留;若整串只有这些禁用 flag,子进程中该变量为未设置。清理只作用于源码分支:打包后的 `worker.cjs` 分支没有原生类型剥离依赖,其继承的选项原样透传。 -这两个前提由真实 worker 启动而非静态门禁保证。加入 value `enum` 这类不可擦除语法,或把仅类型导入退化为值导入,Node 会在 worker 上报之前拒绝入口,表现为 worker 退出类拒绝,而不是预期中的 Win32 对话框错误。 +继承而来的 `--import` 同样会被保留,因此在进程级注册 loader 的宿主会把 `e:` scheme 风险重新放回 worker 路径之前。本启动无法区分插桩 hook 与 TypeScript hook,这种情况仍需宿主自行规避。 ## 相关启动路径 `dsh` CLI 的源码启动保留 tsx ESM hook,因为它的源码图需要 Node 已不再提供的 transform 模式,见[源码启动决策](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md);那条约束针对的是 CLI 源码图,而不是说 engines 范围内没有原生剥离。 -`packages/workflow/workflow-worker-thread/src/host.ts` 同样从裸 `import.meta.url` 选择源码/构建分支,但它的 worker 从携带正确 `file://` href 的 `data:` URL 启动,Windows `e:` scheme 故障触及不到那条启动路径。 +`packages/sandbox/sandbox-local/src/index.ts` 仍在为 windows-acl runner 的源码分支拼出同一个启动向量,而那个源码图同样包内闭合且可擦除,因此同样的启动方式适用。它属于独立改动:一并要改写 `packages/sandbox/sandbox-local/tests/local.spec.ts` 中钉住 `--import tsx/esm` 前缀的断言。 + +`packages/workflow/workflow-worker-thread/src/host.ts` 同样从裸 `import.meta.url` 选择源码/构建分支,但它的 worker 从携带正确 `file://` href 的 `data:` URL 启动,`e:` scheme 故障触及不到那条启动路径。 -## Alternatives considered +## 考虑过的替代方案 **把 worker 作为 `file://` URL 而不是路径传入。** 拒绝:tsx 的 tsconfig-paths hook 会把 `file://` URL 改写成 `\file:\`(`ERR_MODULE_NOT_FOUND`);只要还牵扯 tsx,启动就是脆弱的。 -**探测 koffi 可用性并回退到纯 Node 对话框。** 超出范围:dshcode 固定 koffi 3.1.1,早于损坏的 3.1.3/3.1.4 win32-x64 预编译,因此 worker 对 koffi 的用法并非本代码库的故障点;worker 在 koffi 加载之前就已崩溃。 +**探测 koffi 可用性并回退到纯 Node 对话框。** 超出范围:锁文件把 koffi 解析到 3.1.1,而 worker 在 koffi 加载之前就已崩溃,因此 koffi 并非本代码库的故障点。 -**给子进程显式传入开启 flag,而不是清理 `NODE_OPTIONS`。** 拒绝:Node 已经把该特性的否定写法改过一次(先是 `--no-experimental-strip-types`,后为 `--no-strip-types`),硬编码开启 flag 会把启动绑定到某条 Node 线;移除两种已知的禁用写法不需要这种绑定,也不动宿主的任何无关选项。 +**给子进程显式传入开启 flag,而不是清理 `NODE_OPTIONS`。** 拒绝:Node 已经把该特性的否定写法改过一次(先是 `--no-experimental-strip-types`,后为 `--no-strip-types`),硬编码开启 flag 会把启动绑定到某条 Node 线,而移除两种已知的禁用写法在整个 engines 范围内都成立。 -## Consequences +## 后果 -- Windows 源码启动(`pnpm dsh web`)直接由 Node 原生类型剥离运行 worker,loader 链带来的 `e:` scheme 故障消失。 +- Windows 源码启动(`pnpm dsh web`)直接由 Node 原生类型剥离运行 worker,不再有任何 loader 链会把 worker 路径读成 `e:` scheme URL。 - 打包宿主保持不变的 CJS worker 分支,`NODE_OPTIONS` 不被改写。 -- 源码分支依赖 engines 范围、包内闭合且只含可擦除语法的依赖图、仅类型的相对导入,以及移除继承的类型剥离禁用 flag;`packages/host/directory-picker-native/README.md` 为使用者记录了这些前提。 +- 源码分支依赖 engines 范围、包内闭合且只含可擦除语法的依赖图、标注过的类型导入,以及移除继承的类型剥离禁用 flag;[包 README](../../../../packages/host/directory-picker-native/README.md) 为使用者写明了这些前提。 - 即使模块运行器给 URL 附加查询串,Win32 冒烟测试也能进入真实的源码启动。 ## 验证 - `packages/host/directory-picker-native/tests/win32-dialog-host.spec.ts` 钉住源码启动:由 `process.execPath` 以唯一位置参数运行 worker 路径,不带任何 loader flag。 -- 同一套件钉住 `NODE_OPTIONS` 的三种输入:混合串保留无关条目、只含禁用 flag 的串使该变量为未设置、未设置时保持未设置且父进程不被修改。 -- `tests/win32-dialog.spec.ts` 在 POSIX 上启动真实源码 worker,这正是能抓住依赖图中不可擦除语法或丢失仅类型导入的地方。 +- 同一套件覆盖 `NODE_OPTIONS` 的三种场景——混合串保留无关条目且不改动父进程、只含禁用 flag 的串使该变量为未设置、未设置时保持未设置。 +- `tests/win32-dialog.spec.ts` 在 POSIX 上启动真实源码 worker。出现不可擦除语法或未标注的类型导入时,该启动会在上报前退出,于是测试以 worker 退出类拒绝失败,而不是预期的 `win32 folder dialog failed`。 - 在 win32 上,同一套件通过源码分支真实打开并中止关闭对话框;`tests/built-worker.e2e.ts` 负责本决策未改动的打包 `worker.cjs` 分支。 diff --git a/AGENTS.md b/AGENTS.md index 53f4d17bdb..f83385ad28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,7 +98,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, ## Conventions - Every npm package is `@deepseek-ai/dsh-`; vendored packages are rescoped ([mapping](docs/rescope.md)) and `private: true`. `@deepseek-ai/cordis` is a peerDependency (+ dev) of every harness package. -- ESM everywhere (`"type": "module"`). Use package names across packages and `.ts` in local relative imports. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only exports) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). Raw/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it. +- ESM everywhere (`"type": "module"`). Use package names across packages and `.ts` in local relative imports. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only exports) — Node's native transform mode is gone and its strip mode rejects that graph's syntax ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). Raw/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it. - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. - **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. Without a plausible relationship, an explained empty companion is correct ([package invariant rules](packages/AGENTS.md)). - **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns. A `SessionEventMap` member is required-on-read by default — builds that do not know its type refuse the log unless the event carries the envelope's `ignorable: true`; only structural format changes bump `SESSION_FORMAT_VERSION` ([mechanism](.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)). diff --git a/packages/host/directory-picker-native/README.i18n.yaml b/packages/host/directory-picker-native/README.i18n.yaml index 689dc7461a..3f1641b98d 100644 --- a/packages/host/directory-picker-native/README.i18n.yaml +++ b/packages/host/directory-picker-native/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker-native/README.md -README.md: 10fbb493ff296587db44244565f38cbd155f419f -README.zh.md: 8f4f0a1b6cd2c9b9d542a973ae632b861f33cc22 +README.md: 7fa7fa630135587c193088371622790290e21a99 +README.zh.md: e881d96249322608d815d610ced439799ac2852e diff --git a/packages/host/directory-picker-native/README.md b/packages/host/directory-picker-native/README.md index 10fbb493ff..7fa7fa6301 100644 --- a/packages/host/directory-picker-native/README.md +++ b/packages/host/directory-picker-native/README.md @@ -18,4 +18,4 @@ None; this package neither assembles nor sends a provider request. - **Linux requires desktop tooling** — with neither Zenity nor KDialog installed, `pick` rejects with an actionable error; it does not fall back to a typed-path prompt (the browse backend is that fallback at the composition level). - **Windows has no mechanism fallback** — the child-process picker through packaged koffi is the only native tier, so a COM refusal or dialog crash surfaces the failure. The browse backend remains the fallback at the composition level. -- **Windows source-plane launch depends on native TypeScript stripping** — the source worker is executed directly by Node on the repository engines range (`^22.19.0 || >=24.0.0`), so its package-local dependency graph must remain erasable TypeScript with type-only relative imports. The source child also removes inherited `NODE_OPTIONS` flags that disable native type stripping (`--no-experimental-strip-types` and `--no-strip-types`); unrelated options are preserved. The packaged CJS worker does not have this source-plane dependency. +- **Windows source-plane launch depends on native TypeScript stripping** — Node executes the source worker directly on the repository engines range (`^22.19.0 || >=24.0.0`), so its package-local dependency graph must stay erasable TypeScript and every relative import that names a type must be marked (`import type` or the inline `type` modifier); an unmarked one compiles but fails at load. The source child also drops inherited `NODE_OPTIONS` entries that disable native type stripping (`--no-experimental-strip-types`, `--no-strip-types`), while every other entry is preserved — including an `--import` loader, which reintroduces the launch failure this backend works around. The packaged CJS worker has none of these source-plane dependencies. diff --git a/packages/host/directory-picker-native/README.zh.md b/packages/host/directory-picker-native/README.zh.md index 8f4f0a1b6c..e881d96249 100644 --- a/packages/host/directory-picker-native/README.zh.md +++ b/packages/host/directory-picker-native/README.zh.md @@ -18,4 +18,4 @@ - **Linux 依赖桌面工具**——Zenity 与 KDialog 均未安装时,`pick` 以包含解决建议的错误拒绝;它不会回退为手输路径提示(组合层面的回退是 browse 后端)。 - **Windows 没有机制级回退**——通过打包依赖 koffi 运行的子进程选择器是唯一原生层级,因此 COM 拒绝或对话框崩溃会直接上报失败。组合层面的回退仍是 browse 后端。 -- **Windows 源码层启动依赖原生 TypeScript 类型剥离**——源码 worker 在仓库 engines 范围(`^22.19.0 || >=24.0.0`)内由 Node 直接执行,因此其包内闭合的依赖图必须保持可擦除 TypeScript 且相对导入全部为仅类型导入。源码子进程还会移除继承而来的关闭原生类型剥离的 `NODE_OPTIONS` flag(`--no-experimental-strip-types` 与 `--no-strip-types`);无关选项原样保留。打包后的 CJS worker 没有这项源码层依赖。 +- **Windows 源码面启动依赖原生 TypeScript 类型剥离**——Node 在仓库 engines 范围(`^22.19.0 || >=24.0.0`)内直接执行源码 worker,因此其包内闭合的依赖图必须保持可擦除 TypeScript,且凡是命名类型的相对导入都必须标注(`import type` 或行内 `type` 修饰符);漏标注的导入能编译通过、却会在加载时失败。源码子进程还会移除继承而来的关闭原生类型剥离的 `NODE_OPTIONS` 条目(`--no-experimental-strip-types`、`--no-strip-types`),其余条目一律保留——包括 `--import` loader,而它会把本后端所规避的启动故障重新引入。打包后的 CJS worker 不含这些源码面依赖。 diff --git a/packages/host/directory-picker-native/src/win32-dialog-host.ts b/packages/host/directory-picker-native/src/win32-dialog-host.ts index 22a6e5ddd9..4366821544 100644 --- a/packages/host/directory-picker-native/src/win32-dialog-host.ts +++ b/packages/host/directory-picker-native/src/win32-dialog-host.ts @@ -31,8 +31,10 @@ function sanitizeNodeOptions(value: string | undefined): string | undefined { * Spawn the dialog child process. Built consumers launch the bundled CJS * entry next to this module under plain node; unbuilt (source) consumers * run the worker directly under Node's native type stripping (stable since - * 22.18, covered by the engines range). The source worker dependency graph - * is package-local and uses erasable TS with type-only relative imports. + * 22.18, covered by the engines range). That source arm requires a + * package-local graph whose every type-naming relative import is marked + * (`import type` or the inline `type` modifier): an unmarked one compiles + * and bundles, then fails at load under strip mode. * The dialog is the child's first window, so Windows activates it without a * foreground call. * @param data - the child payload (dialog title). From 497553da83f50dcb103b4d2de007fcbb9e443dc9 Mon Sep 17 00:00:00 2001 From: whitelonng Date: Wed, 19 Aug 2026 16:56:08 +0800 Subject: [PATCH 14/14] docs: name the loader entry that distinguishes this launch from the green ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adversarial review asked what separates this failure from `packages/test-support/loader-smoke/src/index.ts`, which launches `--import ` in src mode from a `.spec.ts` that runs on Windows CI and passes. The discriminator is the registered entry, and it is statically checkable: every green site resolves the full `tsx` entry, while this arm and `sandbox-local`'s windows-acl runner resolve the ESM-only `tsx/esm` hook. That also confirms the sandbox site is the same instance rather than a lookalike, and it matches the rejected `file://` alternative, where tsx's tsconfig-paths hook mangles the URL — the hook sets handle Windows paths differently. Also justify the workflow asymmetry's second half: its raw `import.meta.url` check keeps the query-string hazard, which on a built tree selects `worker.cjs`, so the exposure there is which artifact a test covers rather than a production launch. And restore the enumeration of constructs strip mode refuses, which had shrunk to a value `enum`. --- ...2026-08-19-win32-dialog-worker-source-launch.i18n.yaml | 4 ++-- .../2026-08-19-win32-dialog-worker-source-launch.md | 8 ++++---- .../2026-08-19-win32-dialog-worker-source-launch.zh.md | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.i18n.yaml index 9cf743012c..8621e073de 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md -2026-08-19-win32-dialog-worker-source-launch.md: a61e2cb8b8c2a7bf95098a7aacc18a7c8973e362 -2026-08-19-win32-dialog-worker-source-launch.zh.md: 0f99fa039a2dfd9a30c16b9969dace8aef54c909 +2026-08-19-win32-dialog-worker-source-launch.md: 44eb6634d44c7338bae7ac90c5f50aecfa142bd2 +2026-08-19-win32-dialog-worker-source-launch.zh.md: 9055289fa40a914f6506064b63388520352cf29d diff --git a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md index a61e2cb8b8..44eb6634d4 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md +++ b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md @@ -6,7 +6,7 @@ English | [中文](2026-08-19-win32-dialog-worker-source-launch.zh.md) ## Problem -On Windows, the source-plane folder dialog worker never started: the Web UI reported `win32 folder dialog worker exited before reporting a result`. The failure was in the launch vector, not koffi: the source arm ran `node --import tsx/esm `. With a loader registered through `--import`, an absolute path such as `E:\dsh\packages\host\directory-picker-native\src\win32-dialog-worker.ts` can be read as an `e:` scheme URL and rejected with `ERR_UNSUPPORTED_ESM_URL_SCHEME`, before the worker posts its first IPC message. +On Windows, the source-plane folder dialog worker never started: the Web UI reported `win32 folder dialog worker exited before reporting a result`. The failure was in the launch vector, not koffi: the source arm ran `node --import tsx/esm `. With a loader registered through `--import`, an absolute path such as `E:\dsh\packages\host\directory-picker-native\src\win32-dialog-worker.ts` can be read as an `e:` scheme URL and rejected with `ERR_UNSUPPORTED_ESM_URL_SCHEME`, before the worker posts its first IPC message. The registered entry is what differs from this repository's other absolute-path launches: they register the full `tsx` entry (`packages/test-support/loader-smoke/src/index.ts`) and pass on Windows CI, while this arm registered the ESM-only `tsx/esm` hook. A raw `import.meta.url.endsWith('.ts')` check also decided which arm to launch. Vitest and Vite may decorate a module URL with a query string, and a decorated URL fails that suffix test, so a source-plane test could exercise the built arm — a bundler-specific test hazard rather than a cause of the Windows failure. @@ -30,7 +30,7 @@ Every relative import that names a type is marked, with `import type` or the inl The packaged arm remains `worker.cjs` under plain node. Both arms choose from `new URL(import.meta.url).pathname.endsWith('.ts')`, so a query string on the module URL cannot misclassify a source module as built. -Neither precondition has a static gate; the real worker launch enforces both. A value `enum`, or a type import left unmarked, makes Node reject the entry before the worker posts, which surfaces as the worker-exit rejection instead of the expected Win32 dialog error. +Neither precondition has a static gate; the real worker launch enforces both. A construct strip mode refuses — a value `enum`, a `namespace` with runtime members, a parameter property, a decorator — or a type import left unmarked, makes Node reject the entry before the worker posts, which surfaces as the worker-exit rejection instead of the expected Win32 dialog error. ## Inherited NODE_OPTIONS @@ -47,9 +47,9 @@ An inherited `--import` is preserved like any other entry, so a host that regist The `dsh` CLI source launch keeps the tsx ESM hook because its graph needs a transform mode Node no longer ships, per [the source-launch decision](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md); that constraint is about the CLI graph, not about native stripping being unavailable in the engines range. -`packages/sandbox/sandbox-local/src/index.ts` still builds this vector for the windows-acl runner's source arm, and that graph is package-local and erasable too, so the same launch applies there. It is a separate change: it also rewrites the assertion in `packages/sandbox/sandbox-local/tests/local.spec.ts` that pins the `--import tsx/esm` prefix. +`packages/sandbox/sandbox-local/src/index.ts` still builds this vector — the same ESM-only `tsx/esm` hook in front of an absolute path — for the windows-acl runner's source arm, and that graph is package-local and erasable too, so the same launch applies there. It is a separate change: it also rewrites the assertion in `packages/sandbox/sandbox-local/tests/local.spec.ts` that pins the `--import tsx/esm` prefix. -`packages/workflow/workflow-worker-thread/src/host.ts` selects its own source/built arm from the raw `import.meta.url`, but it boots the worker from a `data:` URL carrying a proper `file://` href, so the `e:` scheme failure cannot reach that launch. +`packages/workflow/workflow-worker-thread/src/host.ts` selects its own source/built arm from the raw `import.meta.url`, but it boots the worker from a `data:` URL carrying a proper `file://` href, so the `e:` scheme failure cannot reach that launch. Its raw check does leave the query-string hazard: on a built tree a decorated URL selects `worker.cjs`, so a source-plane test there can exercise built code — which artifact a test covers, not a production launch. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md index 0f99fa039a..9055289fa4 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -Windows 上源码面的文件夹对话框 worker 从未启动成功:Web UI 只报出 `win32 folder dialog worker exited before reporting a result`。故障出在启动向量而非 koffi:源码分支运行的是 `node --import tsx/esm <绝对路径 .ts>`。通过 `--import` 注册 loader 后,像 `E:\dsh\packages\host\directory-picker-native\src\win32-dialog-worker.ts` 这样的绝对路径可能被读作 `e:` scheme URL 并以 `ERR_UNSUPPORTED_ESM_URL_SCHEME` 拒绝,此时 worker 还没发出第一条 IPC 消息。 +Windows 上源码面的文件夹对话框 worker 从未启动成功:Web UI 只报出 `win32 folder dialog worker exited before reporting a result`。故障出在启动向量而非 koffi:源码分支运行的是 `node --import tsx/esm <绝对路径 .ts>`。通过 `--import` 注册 loader 后,像 `E:\dsh\packages\host\directory-picker-native\src\win32-dialog-worker.ts` 这样的绝对路径可能被读作 `e:` scheme URL 并以 `ERR_UNSUPPORTED_ESM_URL_SCHEME` 拒绝,此时 worker 还没发出第一条 IPC 消息。与仓库中其他「绝对路径」启动的区别在于注册的入口:它们注册的是完整的 `tsx` 入口(`packages/test-support/loader-smoke/src/index.ts`),在 Windows CI 上是绿的,而这个分支注册的是仅 ESM 的 `tsx/esm` hook。 决定启动哪个分支的判断此前读的是裸 `import.meta.url`。Vitest 与 Vite 可能给模块 URL 附加查询串,带查询串的 URL 通不过这个后缀判断,于是源码面的测试可能跑到 built 分支上——这属于 bundler 测试环境的风险,而不是 Windows 故障的成因。 @@ -30,7 +30,7 @@ spawn(process.execPath, [fileURLToPath(new URL('./win32-dialog-worker.ts', impor 打包分支继续由纯 node 启动 `worker.cjs`。两个分支都由 `new URL(import.meta.url).pathname.endsWith('.ts')` 选择,模块 URL 上的查询串无法把源码模块误判为构建产物。 -这两个前提都没有静态门禁,由真实 worker 启动来保证。出现 value `enum`,或有类型导入漏了标注,Node 会在 worker 上报之前拒绝入口,表现为 worker 退出类拒绝,而不是预期中的 Win32 对话框错误。 +这两个前提都没有静态门禁,由真实 worker 启动来保证。出现剥离模式拒绝的语法——value `enum`、带运行时成员的 `namespace`、参数属性、装饰器——或有类型导入漏了标注,Node 会在 worker 上报之前拒绝入口,表现为 worker 退出类拒绝,而不是预期中的 Win32 对话框错误。 ## 继承的 NODE_OPTIONS @@ -47,9 +47,9 @@ spawn(process.execPath, [fileURLToPath(new URL('./win32-dialog-worker.ts', impor `dsh` CLI 的源码启动保留 tsx ESM hook,因为它的源码图需要 Node 已不再提供的 transform 模式,见[源码启动决策](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md);那条约束针对的是 CLI 源码图,而不是说 engines 范围内没有原生剥离。 -`packages/sandbox/sandbox-local/src/index.ts` 仍在为 windows-acl runner 的源码分支拼出同一个启动向量,而那个源码图同样包内闭合且可擦除,因此同样的启动方式适用。它属于独立改动:一并要改写 `packages/sandbox/sandbox-local/tests/local.spec.ts` 中钉住 `--import tsx/esm` 前缀的断言。 +`packages/sandbox/sandbox-local/src/index.ts` 仍在为 windows-acl runner 的源码分支拼出同一个启动向量——同样是仅 ESM 的 `tsx/esm` hook 加绝对路径,而那个源码图同样包内闭合且可擦除,因此同样的启动方式适用。它属于独立改动:一并要改写 `packages/sandbox/sandbox-local/tests/local.spec.ts` 中钉住 `--import tsx/esm` 前缀的断言。 -`packages/workflow/workflow-worker-thread/src/host.ts` 同样从裸 `import.meta.url` 选择源码/构建分支,但它的 worker 从携带正确 `file://` href 的 `data:` URL 启动,`e:` scheme 故障触及不到那条启动路径。 +`packages/workflow/workflow-worker-thread/src/host.ts` 同样从裸 `import.meta.url` 选择源码/构建分支,但它的 worker 从携带正确 `file://` href 的 `data:` URL 启动,`e:` scheme 故障触及不到那条启动路径。它的裸判断确实留下了查询串风险:在已构建的树上,带查询串的 URL 会选中 `worker.cjs`,于是那里的源码面测试可能跑到构建产物上——这影响测试覆盖的是哪个产物,而非生产启动。 ## 考虑过的替代方案