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..8621e073de --- /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: 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 new file mode 100644 index 0000000000..44eb6634d4 --- /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 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. + +## Decision + +Run the source worker directly under Node's native type stripping: + +```ts +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +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. + +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. + +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 + +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 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. + +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/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. 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 + +**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: 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 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 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, 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 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 new file mode 100644 index 0000000000..9055289fa4 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md @@ -0,0 +1,74 @@ +# 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:源码分支运行的是 `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 故障的成因。 + +## 决策 + +源码 worker 直接由 Node 原生类型剥离运行: + +```ts +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +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` 投射。 + +凡是命名类型的相对导入都必须标注,用 `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`、带运行时成员的 `namespace`、参数属性、装饰器——或有类型导入漏了标注,Node 会在 worker 上报之前拒绝入口,表现为 worker 退出类拒绝,而不是预期中的 Win32 对话框错误。 + +## 继承的 NODE_OPTIONS + +源码 worker 会继承宿主的 `NODE_OPTIONS`,支持的 Node 范围内两种关闭原生类型剥离的写法都会从子进程环境中移除: + +- `--no-experimental-strip-types` +- `--no-strip-types` + +其余条目全部保留;若整串只有这些禁用 flag,子进程中该变量为未设置。清理只作用于源码分支:打包后的 `worker.cjs` 分支没有原生类型剥离依赖,其继承的选项原样透传。 + +继承而来的 `--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/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 故障触及不到那条启动路径。它的裸判断确实留下了查询串风险:在已构建的树上,带查询串的 URL 会选中 `worker.cjs`,于是那里的源码面测试可能跑到构建产物上——这影响测试覆盖的是哪个产物,而非生产启动。 + +## 考虑过的替代方案 + +**把 worker 作为 `file://` URL 而不是路径传入。** 拒绝:tsx 的 tsconfig-paths hook 会把 `file://` URL 改写成 `\file:\`(`ERR_MODULE_NOT_FOUND`);只要还牵扯 tsx,启动就是脆弱的。 + +**探测 koffi 可用性并回退到纯 Node 对话框。** 超出范围:锁文件把 koffi 解析到 3.1.1,而 worker 在 koffi 加载之前就已崩溃,因此 koffi 并非本代码库的故障点。 + +**给子进程显式传入开启 flag,而不是清理 `NODE_OPTIONS`。** 拒绝:Node 已经把该特性的否定写法改过一次(先是 `--no-experimental-strip-types`,后为 `--no-strip-types`),硬编码开启 flag 会把启动绑定到某条 Node 线,而移除两种已知的禁用写法在整个 engines 范围内都成立。 + +## 后果 + +- Windows 源码启动(`pnpm dsh web`)直接由 Node 原生类型剥离运行 worker,不再有任何 loader 链会把 worker 路径读成 `e:` scheme URL。 +- 打包宿主保持不变的 CJS worker 分支,`NODE_OPTIONS` 不被改写。 +- 源码分支依赖 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。出现不可擦除语法或未标注的类型导入时,该启动会在上报前退出,于是测试以 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 dc1cf3adab..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: 414dee8e0a064277dfd153196c17c780cb31bcb0 -README.zh.md: 3463f149d175ae9c2b95cc5eb25fe9b3762df902 +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 414dee8e0a..7fa7fa6301 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** — 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 3463f149d1..e881d96249 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 类型剥离**——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/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..4366821544 100644 --- a/packages/host/directory-picker-native/src/win32-dialog-host.ts +++ b/packages/host/directory-picker-native/src/win32-dialog-host.ts @@ -11,25 +11,50 @@ 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 - * 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). 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). * @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' } + 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 (!import.meta.url.endsWith('.ts')) { - return spawn(process.execPath, [fileURLToPath(new URL('./worker.cjs', import.meta.url))], { env, stdio, windowsHide: true }) + if (!new URL(import.meta.url).pathname.endsWith('.ts')) { + return spawn(process.execPath, [fileURLToPath(new URL('./worker.cjs', import.meta.url))], { env: baseEnv, stdio, windowsHide: true }) } - return spawn(process.execPath, ['--import', import.meta.resolve('tsx/esm'), fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], { env, stdio, windowsHide: true }) + // `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 }) } 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..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 @@ -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,39 @@ describe('spawnDialogWorker', () => { }) expect(process.env.ELECTRON_RUN_AS_NODE).toBe('') }) + + 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('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' }) + + expect(spawnMock).toHaveBeenCalledOnce() + 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))]) + }) }) 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: