فهرست منبع

Merge pull request #2299 from deepseek-harness/fix/windows-native-ci-local-validation

fix: Windows-native CI findings on latest master (local gate reproduction)
Chinesezjc 1 ماه پیش
والد
کامیت
fc4405723f
29فایلهای تغییر یافته به همراه392 افزوده شده و 35 حذف شده
  1. 6 0
      .agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.i18n.yaml
  2. 23 0
      .agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.md
  3. 23 0
      .agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.zh.md
  4. 6 0
      .agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.i18n.yaml
  5. 23 0
      .agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.md
  6. 23 0
      .agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.zh.md
  7. 6 0
      .agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.i18n.yaml
  8. 25 0
      .agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.md
  9. 25 0
      .agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.zh.md
  10. 2 2
      docs/defensive-patterns.i18n.yaml
  11. 4 0
      docs/defensive-patterns.md
  12. 5 1
      docs/defensive-patterns.zh.md
  13. 4 2
      packages/boot/app-boot/src/profile.ts
  14. 2 2
      packages/boot/app-boot/tests/hmr-config.spec.ts
  15. 7 2
      packages/context/agent-instructions/tests/agent-instructions.spec.ts
  16. 2 2
      packages/shell/pwsh-local/README.i18n.yaml
  17. 1 1
      packages/shell/pwsh-local/README.md
  18. 1 1
      packages/shell/pwsh-local/README.zh.md
  19. 21 2
      packages/shell/pwsh-local/src/resolve.ts
  20. 28 3
      packages/shell/pwsh-local/tests/executor.spec.ts
  21. 17 1
      packages/subagent/subagent-claude-code/tests/real-product.spec.ts
  22. 1 1
      packages/subagent/subagent/tests/continuation.spec.ts
  23. 1 1
      packages/subagent/subagent/tests/list-children.spec.ts
  24. 1 1
      packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts
  25. 32 4
      packages/workflow/workflow-worker-thread/src/host.ts
  26. 38 6
      packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts
  27. 6 1
      scripts/install-lefthook.spec.ts
  28. 49 0
      scripts/test-fixture-cleanup.ts
  29. 10 2
      scripts/translation-pairing-merge.spec.ts

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.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-12-resolve-store-pwsh-aliases.md
+2026-08-12-resolve-store-pwsh-aliases.md: 20fe58e15e75462dc0a9ba76c7a1a94939f8a004
+2026-08-12-resolve-store-pwsh-aliases.zh.md: bbfa4616127a9dbdb6609fe2973283663de55b31

+ 23 - 0
.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.md

@@ -0,0 +1,23 @@
+# Agent Note: Resolve Microsoft Store pwsh aliases
+
+Status: implemented
+
+English | [中文](2026-08-12-resolve-store-pwsh-aliases.zh.md)
+
+## Problem
+
+`resolvePwshPath` documented that Microsoft Store installs resolve through PATH, but its existence probe was `existsSync`, which stats a candidate and therefore follows reparse points. The Store's `%LOCALAPPDATA%\Microsoft\WindowsApps\pwsh.exe` is an app execution alias whose target directory ACL refuses stat (EACCES), so `existsSync` missed it and resolution silently fell through to Windows PowerShell 5.1 on hosts whose only PowerShell 7 is a Store install.
+
+## Decision
+
+`candidateExists` accepts a candidate that stats as a file or that lstat sees as a link-shaped reparse point, and `resolvePwshPath` uses it. Spawning the alias path works because CreateProcess resolves app execution aliases. A dangling link-shaped candidate is accepted so a broken pwsh fails loudly at spawn instead of silently downgrading to 5.1.
+
+## Alternatives considered
+
+**Probe the WindowsApps package directory directly.** The Store package path is versioned and ACL-hidden; hard-coding it duplicates packaging knowledge that PATH plus the alias already owns.
+
+**Keep the 5.1 fallback for stat failures.** Rejected: it silently runs a different shell than the one installed, which is the defect this note fixes.
+
+## Consequences
+
+Store-installed PowerShell 7 now resolves ahead of the 5.1 fallback on Windows; real-file candidates and non-Windows behavior are unchanged. The dangling-symlink unit test pins the stat/lstat split on every platform.

+ 23 - 0
.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.zh.md

@@ -0,0 +1,23 @@
+# Agent Note: 解析 Microsoft Store 的 pwsh 别名
+
+Status: implemented
+
+[English](2026-08-12-resolve-store-pwsh-aliases.md) | 中文
+
+## 问题
+
+`resolvePwshPath` 声称 Store 安装经 PATH 解析,但它的存在性探测用的是 `existsSync`,会对候选做 stat、从而跟随重解析点。Store 的 `%LOCALAPPDATA%\Microsoft\WindowsApps\pwsh.exe` 是 app execution alias,其目标目录的 ACL 拒绝 stat(EACCES),于是 `existsSync` 看不到它,解析静默落到 Windows PowerShell 5.1——在这类「唯一的 PowerShell 7 是 Store 安装」的机器上就用了错误的 shell。
+
+## 决策
+
+`candidateExists` 接受「stat 为文件」或「lstat 为链接形态重解析点」的候选,`resolvePwshPath` 改用它。spawn 别名路径可以工作,因为 CreateProcess 会解析 app execution alias。悬空的链接形态候选同样被接受,让损坏的 pwsh 在 spawn 时响亮失败,而不是静默降级到 5.1。
+
+## 考虑过的替代方案
+
+**直接探测 WindowsApps 包目录。** Store 包路径带版本且被 ACL 隐藏;硬编码它只是重复了 PATH 加别名已经拥有的打包知识。
+
+**对 stat 失败继续走 5.1 回退。** 否决:它静默运行了一个并非所装的 shell,这正是本 note 修复的缺陷。
+
+## 后果
+
+Windows 上 Store 安装的 PowerShell 7 现在先于 5.1 回退被解析;普通文件候选和非 Windows 平台行为不变。悬空 symlink 单元测试在全部平台上钉住 stat/lstat 的分裂行为。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.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-12-unlink-fixture-junctions-before-delete.md
+2026-08-12-unlink-fixture-junctions-before-delete.md: 4514a33d728866b817f4e9c1393f16c07976ed45
+2026-08-12-unlink-fixture-junctions-before-delete.zh.md: 3c212c052ab0303ccb8d31d2b310a365a1d8cc99

+ 23 - 0
.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.md

@@ -0,0 +1,23 @@
+# Agent Note: Unlink fixture junctions before recursive deletion
+
+Status: implemented
+
+English | [中文](2026-08-12-unlink-fixture-junctions-before-delete.zh.md)
+
+## Problem
+
+The install-lefthook and translation-pairing fixtures junction the repository's real `scripts/`, `node_modules`, and tsx package directories into fixture trees so installer probes resolve through them. Windows recursive deletion can treat a junction (a MOUNT_POINT reparse point) as a directory and follow it into its target; Git's `worktree remove` did exactly that and deleted the repository's tracked `scripts/` and tsx package (the incident's instrumentation pinned the deletion to that step). A fixture cleanup that trusts its deleter therefore deletes the repository's own sources instead of the fixture.
+
+## Decision
+
+`scripts/test-fixture-cleanup.ts` owns junction-safe fixture teardown: `unlinkFixtureLinks` walks a tree and unlinks every reparse point before `removeFixtureSafely` removes the now link-free tree (with Windows async-handle retries). Every affected `afterEach` and the pre-`worktree remove` hook call it. The general rule lives in `docs/defensive-patterns.md`: remove link-shaped paths with unlink, reserve recursive `rmSync` for known real directories.
+
+## Alternatives considered
+
+**Trust recursive deletion alone.** Rejected: whether a given deleter follows junctions is tool- and version-dependent, and one path through `git worktree remove` already destroyed tracked files; no cleanup may bet the repository on that behavior.
+
+**Copy instead of junctioning the real directories.** Rejected: the fixtures exist to probe the real installer paths through their real contents, so copies would stop exercising the boundary under test.
+
+## Consequences
+
+Fixture teardown can no longer reach repository sources through junctions. The extra walk is one lstat/unlink pass over small fixture trees. The data-destroying defect now has its durable why beside the defensive-patterns rule, and the helper is the shared teardown path for future junction fixtures.

+ 23 - 0
.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.zh.md

@@ -0,0 +1,23 @@
+# Agent Note: 递归删除前先解链 fixture junction
+
+Status: implemented
+
+[English](2026-08-12-unlink-fixture-junctions-before-delete.md) | 中文
+
+## 问题
+
+install-lefthook 与 translation-pairing 的 fixture 把仓库真实的 `scripts/`、`node_modules` 和 tsx 包目录用 junction 链进 fixture 树,让 installer 探测能穿透解析。Windows 的递归删除可能把 junction(MOUNT_POINT 重解析点)当作目录并跟随进其目标;Git 的 `worktree remove` 正是这样删掉了仓库被跟踪的 `scripts/` 和 tsx 包(事故的插桩把删除定位到这一步)。因此,信任删除器的 fixture 清理删掉的是仓库自己的源码,而不是 fixture。
+
+## 决策
+
+`scripts/test-fixture-cleanup.ts` 拥有 junction 安全的 fixture 拆除:`unlinkFixtureLinks` 先遍历并解链所有重解析点,`removeFixtureSafely` 再删除已无链接的树(带 Windows 异步句柄重试)。所有受影响的 `afterEach` 和 `worktree remove` 前的钩子都调用它。通用规则记录在 `docs/defensive-patterns.md`:链接形态的路径用 unlink 删除,递归 `rmSync` 只留给确知为真实目录的路径。
+
+## 考虑过的替代方案
+
+**只信任递归删除。** 否决:特定删除器是否跟随 junction 随工具和版本而异,而 `git worktree remove` 这一条路径已经摧毁过被跟踪文件;任何清理都不该拿仓库去赌这个行为。
+
+**复制而不是 junction 真实目录。** 否决:fixture 的意义就是用真实内容探测真实 installer 路径,复制品会失去被测边界。
+
+## 后果
+
+fixture 拆除不再能穿过 junction 触及仓库源码。额外开销只是对小型 fixture 树的一趟 lstat/unlink。这个摧毁数据的缺陷现在在 defensive-patterns 规则旁有了持久化的原因,helper 也是未来所有 junction fixture 共享的拆除路径。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.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-12-unlink-stale-profile-fallback-links.md
+2026-08-12-unlink-stale-profile-fallback-links.md: 32959eb1b83bcc290d1daa4e4a020a2721be489b
+2026-08-12-unlink-stale-profile-fallback-links.zh.md: 1f4da12748c9b57c12bf41a740001d5df770beb6

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.md

@@ -0,0 +1,25 @@
+# Agent Note: Unlink stale profile fallback links instead of rmSync
+
+Status: implemented
+
+English | [中文](2026-08-12-unlink-stale-profile-fallback-links.zh.md)
+
+## Problem
+
+`healProfilesModuleFallback` re-points `$DSH_HOME/profiles/node_modules` entries when an installation moves, and Windows hosts keep those entries as junctions. `ensureSymlink` deleted a stale entry with `rmSync(link)`, but Node treats a junction as a directory for removal: without `recursive`, `rmSync` throws `ERR_FS_EISDIR`, so every launch from a moved installation or a second worktree crashed before booting. The `replaces a wrong symlink` unit test reproduces that crash on Windows at the exact removal call.
+
+## Decision
+
+`ensureSymlink` removes a stale link with `unlinkSync(link)`. `unlink` deletes the reparse point or symlink itself on every platform and never descends into the target, which preserves the function's fail-loud guarantee that a real directory is never deleted. The [profile-plugin-bundles decision](../architecture/2026-08-05-profile-plugin-bundles.md) keeps owning the fallback's two-anchor resolution; this note owns only the removal primitive.
+
+## Alternatives considered
+
+**`rmSync(link, { recursive: true })`.** On Node 24 this deletes the junction without following its target, but `recursive` would silently delete a real directory that replaced the link between the `lstat` guard and the removal, weakening the fail-loud contract that motivates the guard.
+
+**`rmdirSync(link)`.** Removes a junction on Windows as well, but it reads as directory removal for a link, and `unlinkSync` is the repository's existing junction-cleanup idiom.
+
+**Delete and recreate every entry unconditionally.** Correct but churns unchanged links on every launch and widens the concurrent-heal race window.
+
+## Consequences
+
+Windows launches heal moved or second-checkout installations instead of crashing with `ERR_FS_EISDIR`; POSIX behavior is unchanged because `unlinkSync` also unlinks plain symlinks. The existing `replaces a wrong symlink` test now passes on Windows where it previously reproduced the crash. Two concurrent healers deleting the same stale link still surface the second deletion as `ENOENT`, unchanged from the previous `rmSync` implementation.

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: 用 unlink 删除过期的 profile 回退链接而非 rmSync
+
+Status: implemented
+
+[English](2026-08-12-unlink-stale-profile-fallback-links.md) | 中文
+
+## 问题
+
+`healProfilesModuleFallback` 在安装位置迁移时会把 `$DSH_HOME/profiles/node_modules` 中的条目重新指向新目标,而 Windows 主机上这些条目是 junction。`ensureSymlink` 原先用 `rmSync(link)` 删除过期条目,但 Node 在删除时把 junction 当作目录处理:不带 `recursive` 的 `rmSync` 会抛 `ERR_FS_EISDIR`,于是从迁移后的安装或第二个 worktree 启动时,每次都会在应用引导前崩溃。`replaces a wrong symlink` 单元测试在 Windows 上正好在该删除调用处复现了这一崩溃。
+
+## 决策
+
+`ensureSymlink` 改用 `unlinkSync(link)` 删除过期链接。`unlink` 在所有平台上都只删除重解析点或符号链接本身、绝不进入目标目录,从而保住该函数“真实目录永远不会被删除”的大声失败保证。[profile-plugin-bundles 决策](../architecture/2026-08-05-profile-plugin-bundles.md)继续拥有回退目录的双锚点解析;本 note 只拥有“用哪个删除原语”这一决定。
+
+## 考虑过的替代方案
+
+**`rmSync(link, { recursive: true })`。** Node 24 上它只删 junction、不跟随目标,但 `recursive` 会在 `lstat` 守卫与删除之间链接被替换成真实目录时静默删除该目录,削弱守卫存在所依据的大声失败契约。
+
+**`rmdirSync(link)`。** Windows 上同样能删 junction,但它读起来像“删目录”,而 `unlinkSync` 才是仓库现有的 junction 清理惯例。
+
+**无条件删除并重建所有条目。** 正确,但每次启动都翻动未变化的链接,并扩大并发修复的竞态窗口。
+
+## 后果
+
+Windows 启动现在可以修复迁移后的安装或第二个 checkout,而不是以 `ERR_FS_EISDIR` 崩溃;POSIX 行为不变,因为 `unlinkSync` 同样能 unlink 普通符号链接。现有的 `replaces a wrong symlink` 测试在 Windows 上从复现崩溃变为通过。两个并发 healer 删除同一过期链接时,第二次删除仍会以 `ENOENT` 浮现,与原先的 `rmSync` 实现一致。

+ 2 - 2
docs/defensive-patterns.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 docs/defensive-patterns.md
-defensive-patterns.md: b6e643cad6180ea35a363f2c4131b2c24f5f70af
-defensive-patterns.zh.md: e1a6abdfd2138a51a13ceaf418bf6df1d0c65579
+defensive-patterns.md: 9db582354628a13abd43c5bd052dbbfd6e52f79f
+defensive-patterns.zh.md: 7bebbe3c1964f2b826afc523eaa4af7be180e36e

+ 4 - 0
docs/defensive-patterns.md

@@ -27,3 +27,7 @@ A user-supplied listener that throws must not reject the promise it runs inside
 ## Never hand untrusted output the ambient environment or predictable paths
 
 Spawned commands get a scrubbed env (drop `*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASSWORD*`) so harness credentials cannot leak into output, `env`, or spill files. Temp/spill files use a private (0700) dir, random names, and exclusive owner-only opens (`'wx'`, `0o600`) — predictable world-readable paths invite symlink races and disclosure.
+
+## Unlink link-shaped paths
+
+A path that may be a symlink or Windows junction is removed with `lstatSync().isSymbolicLink()` then `unlinkSync`: unlink deletes only the link and refuses a real directory, so it never follows the link into its target. Windows `rmSync(link)` throws `ERR_FS_EISDIR` on a junction; recursive deletion may descend through one into its target. Reserve recursive `rmSync` for known real directories.

+ 5 - 1
docs/defensive-patterns.zh.md

@@ -26,4 +26,8 @@
 
 ## 绝不将环境变量或可预测路径暴露给不可信输出
 
-启动的命令应使用经过清理的环境变量,移除名称匹配 `*KEY*`、`*SECRET*`、`*TOKEN*` 或 `*PASSWORD*` 的项,防止 harness 凭证通过命令输出、`env` 或 spill 文件泄漏。临时文件和 spill 文件应放在权限为 0700 的私有目录中,使用随机文件名,并以独占且仅所有者可访问的方式打开(`'wx'`、`0o600`);可预测且所有用户均可读的路径会引发符号链接竞态和信息泄露。
+启动的命令应使用经过清理的环境变量,移除名称匹配 `*KEY*`、`*SECRET*`、`*TOKEN*` 或 `*PASSWORD*` 的项,防止 harness 凭证通过命令输出、`env` 或 spill 文件泄漏。临时文件和 spill 文件应放在权限为 0700 的私有目录中,使用随机文件名,并以独占且仅所有者可访问的方式打开(`'wx'`、`0o600`);可预测且全局可读的路径会引发符号链接竞态和信息泄露。
+
+## 用 unlink 删除链接形态的路径
+
+可能是符号链接或 Windows junction 的路径,应先用 `lstatSync().isSymbolicLink()` 判断,再用 `unlinkSync` 删除:unlink 只删除链接本身并拒绝真实目录,因此绝不会跟随链接进入其目标。Windows 上对 junction 调用 `rmSync(link)` 会抛 `ERR_FS_EISDIR`;递归删除可能穿过 junction 进入其目标。真实目录才使用带 `recursive` 的 `rmSync`。

+ 4 - 2
packages/boot/app-boot/src/profile.ts

@@ -24,7 +24,7 @@
 
 import { createRequire } from 'node:module'
 import {
-  existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync,
+  existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync,
 } from 'node:fs'
 import { basename, dirname, join } from 'node:path'
 import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
@@ -182,7 +182,9 @@ function ensureSymlink(link: string, target: string): void {
       throw new Error(`dsh: ${link} exists and is not a symlink; remove it so dsh can manage the installation fallback`)
     }
     if (readlinkSync(link) === target) return
-    rmSync(link)
+    // unlink deletes the reparse point itself on Windows too; rmSync treats a
+    // junction as a directory and throws EISDIR unless recursive.
+    unlinkSync(link)
   }
   try {
     symlinkSync(target, link, 'junction')

+ 2 - 2
packages/boot/app-boot/tests/hmr-config.spec.ts

@@ -62,7 +62,7 @@ describe('HMR exact config paths', () => {
       expect(cacheHas).toHaveBeenCalledWith(expected)
     } finally {
       await ctx.fiber.dispose()
-      rmSync(alias, { force: true })
+      unlinkSync(alias)
       rmSync(target, { recursive: true, force: true })
     }
   })
@@ -78,7 +78,7 @@ describe('HMR exact config paths', () => {
         .rejects.toThrow('config path already registered')
     } finally {
       await ctx.fiber.dispose()
-      rmSync(alias, { force: true })
+      unlinkSync(alias)
       rmSync(target, { recursive: true, force: true })
     }
   })

+ 7 - 2
packages/context/agent-instructions/tests/agent-instructions.spec.ts

@@ -578,10 +578,12 @@ describe('workspace context instruction discovery', () => {
     const root = await tempRepo()
     const emptyHome = await tempRepo()
     // Isolate the default-home fallback: blank DSH_HOME is treated as unset, and
-    // HOME points at an empty dir so the default ~/.dsh holds no global scope.
-    // Symlinks are followed, so a real ~/.dsh/AGENTS.md would otherwise leak in.
+    // the home dirs point at an empty dir so the default ~/.dsh holds no global
+    // scope. Windows homedir() reads USERPROFILE (not HOME), so both must be
+    // stubbed or a real ~/.dsh/AGENTS.md would otherwise leak in.
     vi.stubEnv('DSH_HOME', '')
     vi.stubEnv('HOME', emptyHome)
+    if (process.platform === 'win32') vi.stubEnv('USERPROFILE', emptyHome)
     try {
       const cwd = join(root, 'child')
       await mkdir(cwd, { recursive: true })
@@ -622,6 +624,8 @@ describe('workspace context instruction discovery', () => {
     try {
       await write(join(home, '.dsh/AGENTS.md'), 'global default rule')
 
+      // A set DSH_HOME would override the homedir default and relabel the home.
+      vi.stubEnv('DSH_HOME', '')
       vi.resetModules()
       vi.doMock('node:os', () => ({ homedir: () => home }))
       const isolated = await import('@deepseek-ai/dsh-agent-instructions')
@@ -629,6 +633,7 @@ describe('workspace context instruction discovery', () => {
 
       expect(files.map(file => file.displayPath)).toEqual(['~/.dsh/AGENTS.md'])
     } finally {
+      vi.unstubAllEnvs()
       vi.doUnmock('node:os')
       vi.resetModules()
       await rm(root, { recursive: true, force: true })

+ 2 - 2
packages/shell/pwsh-local/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/shell/pwsh-local/README.md
-README.md: cfb58b569022bca11d18c196e0bce104247e4c81
-README.zh.md: 9393c5d210743d8423457f2d7df5900c1e0df182
+README.md: 2eccc59b919d1f729eef52a42581f4da0f1d9e60
+README.zh.md: a03f80343711a1471bc96017e23476a3253ec46c

+ 1 - 1
packages/shell/pwsh-local/README.md

@@ -30,7 +30,7 @@ The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantic
 - **Spawn per call, no shell state** — every call is a fresh non-interactive `pwsh -Command` (deterministic; no profile files). The `-NoLogo -NoProfile -NonInteractive` flags disable startup banners, profile loading, and prompts that would garble tool output.
 - **The composition entry is a layer, not the last word** — when a settings provider is composed, this executor registers the capability's [`bash` namespace](../shell/README.md) with the entry above as its base, so a user section in `settings.yaml` layers over it and the next command runs with the new budgets. The namespace is shared with the POSIX family because a host composes exactly one provider of `ctx.shell`; a document written on either platform keeps resolving on the other. Values the schema cannot judge (positive and finite, the `graceMs` timer bound) are refused at the write, leaving the running executor on its last good section.
 - **UTF-8 output pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. Input encoding is left at the host default; pwsh 7 defaults to UTF-8 and is unaffected.
-- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)`; it runs at construction and again only when a stored `pwshPath` differs from the one the current executable was resolved from, so an unrelated settings change never re-probes the filesystem.
+- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking each candidate with an lstat probe that accepts a real file or a link-shaped reparse point (a Store app execution alias stat-fails against its target's ACL, but lstat sees the alias itself); elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)`; it runs at construction and again only when a stored `pwshPath` differs from the one the current executable was resolved from, so an unrelated settings change never re-probes the filesystem.
 - **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `ShellExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
 - **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent.
 - **Model-friendly terminal env** — `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb`: that is a POSIX concept; `NO_COLOR` is honored by modern PowerShell renderers) merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins.

+ 1 - 1
packages/shell/pwsh-local/README.zh.md

@@ -30,7 +30,7 @@
 - **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。
 - **组装条目是一层,而不是最终值**——当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../shell/README.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。该命名空间与 POSIX 家族共用,因为一个宿主只组装一个 `ctx.shell` 提供方;在任一平台写下的文档在另一平台仍能解析。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段。
 - **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding` 与 `$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出:subprocess 收集器以 UTF-8 解码字节。输入编码保持宿主默认;pwsh 7 默认为 UTF-8,不受影响。
-- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数;它在构造时执行,此后仅当存储的 `pwshPath` 与当前可执行文件所依据的值不同才再次执行,因此无关的设置变更绝不会重新探测文件系统。
+- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一用 lstat 探测检查(接受真实文件或链接形态的重解析点:Store 的 app execution alias 对其目标 stat 会因 ACL 失败,但 lstat 能看到别名本身);其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数;它在构造时执行,此后仅当存储的 `pwshPath` 与当前可执行文件所依据的值不同才再次执行,因此无关的设置变更绝不会重新探测文件系统。
 - **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `ShellExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。
 - **超时与取消分类**——`run()` 通过一个 deadline 融合按配置上限截取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。Windows 将强制终止报告为退出码 1 且无信号,因此带信号标记的事实(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。
 - **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。

+ 21 - 2
packages/shell/pwsh-local/src/resolve.ts

@@ -8,7 +8,7 @@
  * @module @deepseek-ai/dsh-pwsh-local/resolve
  */
 
-import { existsSync } from 'node:fs'
+import { lstatSync } from 'node:fs'
 import { join } from 'node:path'
 
 /**
@@ -36,6 +36,25 @@ export function candidatePwshPaths(env: NodeJS.ProcessEnv = process.env): string
   return candidates
 }
 
+/**
+ * Whether a candidate can be spawned. lstat opens the entry itself instead of
+ * following reparse points, so it sees the Store app execution alias where
+ * stat hits the target's ACL (EACCES); Node reports that alias as a symlink
+ * on current releases and as a plain file on older ones, and CreateProcess
+ * resolves either shape. A real directory never matches.
+ */
+function candidateExists(candidate: string): boolean {
+  try {
+    const stat = lstatSync(candidate)
+    return stat.isFile() || stat.isSymbolicLink()
+  } catch {
+    // ENOENT (the candidate vanished between listing and probing) is the only
+    // expected failure; any other error names an unspawnable path, so false
+    // is the safe answer for it too.
+    return false
+  }
+}
+
 /**
  * Resolve the pwsh executable this executor spawns.
  * @param configured - an explicit `pwshPath` config value, trusted as-is.
@@ -53,7 +72,7 @@ export function resolvePwshPath(
   if (configured !== undefined && configured.length > 0) return configured
   if (platform === 'win32') {
     for (const candidate of candidatePwshPaths(env)) {
-      if (existsSync(candidate)) return candidate
+      if (candidateExists(candidate)) return candidate
     }
   }
   return 'pwsh'

+ 28 - 3
packages/shell/pwsh-local/tests/executor.spec.ts

@@ -9,7 +9,7 @@
  * writes CRLF on Windows, so exact text assertions normalize line endings.
  */
 
-import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { spawnSync } from 'node:child_process'
@@ -126,6 +126,29 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () =>
     expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: join(dir, 'empty'), SystemRoot: join(dir, 'no-windows') }, 'win32'))
       .toBe('pwsh')
   })
+
+  it('accepts a link-shaped PATH candidate whose target cannot be stat-ed', () => {
+    // Store app execution aliases stat as EACCES but lstat as a link; a
+    // dangling symlink reproduces that split on every platform.
+    const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-link-'))
+    const store = join(dir, 'store')
+    mkdirSync(store, { recursive: true })
+    const link = join(store, 'pwsh.exe')
+    symlinkSync(join(dir, 'no-such-target.exe'), link)
+    expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: store }, 'win32'))
+      .toBe(link)
+  })
+
+  it('skips a directory candidate and falls through to the PATH-resolution default', () => {
+    const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-dir-'))
+    const store = join(dir, 'store')
+    mkdirSync(join(store, 'pwsh.exe'), { recursive: true })
+    expect(resolvePwshPath(undefined, {
+      ProgramFiles: join(dir, 'missing'),
+      PATH: store,
+      SystemRoot: join(dir, 'no-windows'),
+    }, 'win32')).toBe('pwsh')
+  })
 })
 
 describe('spawn construction (pure, every platform)', () => {
@@ -298,8 +321,10 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)'
   it('start returns immediately with a running handle that settles as completed', async () => {
     const { bash } = await setup()
     const before = Date.now()
-    const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Milliseconds 200; Write-Output done' }))
-    expect(Date.now() - before).toBeLessThan(150)
+    // The sleep outlasts any realistic spawn latency, so returning while the
+    // child still sleeps proves start() does not wait for completion.
+    const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Milliseconds 2000; Write-Output done' }))
+    expect(Date.now() - before).toBeLessThan(1000)
     expect(proc.status).toBe('running')
     await proc.done
     expect(proc.status).toBe('completed')

+ 17 - 1
packages/subagent/subagent-claude-code/tests/real-product.spec.ts

@@ -17,7 +17,7 @@ import type {
   SDKSystemMessage,
 } from '@anthropic-ai/claude-agent-sdk'
 import { Context } from '@deepseek-ai/cordis'
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import SubagentRuntime from '@deepseek-ai/dsh-subagent'
 import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
@@ -87,6 +87,22 @@ const roots: string[] = []
 const fixtures: MessagesFixture[] = []
 const contexts: Context[] = []
 
+// Ambient Anthropic model env leaks into the real CLI and overrides the
+// fixture settings.json on developer machines; delete it for this file and
+// restore it after, like the workspace-context USERPROFILE isolation.
+const ambientAnthropicModel = process.env.ANTHROPIC_MODEL
+const ambientAnthropicSmallFastModel = process.env.ANTHROPIC_SMALL_FAST_MODEL
+
+beforeAll(() => {
+  delete process.env.ANTHROPIC_MODEL
+  delete process.env.ANTHROPIC_SMALL_FAST_MODEL
+})
+
+afterAll(() => {
+  if (ambientAnthropicModel !== undefined) process.env.ANTHROPIC_MODEL = ambientAnthropicModel
+  if (ambientAnthropicSmallFastModel !== undefined) process.env.ANTHROPIC_SMALL_FAST_MODEL = ambientAnthropicSmallFastModel
+})
+
 afterEach(async () => {
   await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
   await Promise.all(fixtures.splice(0).map(fixture => fixture.close()))

+ 1 - 1
packages/subagent/subagent/tests/continuation.spec.ts

@@ -53,7 +53,7 @@ class GatedAdapter extends LlmAdapter {
 
 const roots: string[] = []
 afterEach(() => {
-  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
 })
 
 /** Boot the full continuable stack: loop, persistence, providers, and subagents. */

+ 1 - 1
packages/subagent/subagent/tests/list-children.spec.ts

@@ -28,7 +28,7 @@ type Script = ConstructorParameters<typeof MockAdapter>[0]
 
 const roots: string[] = []
 afterEach(() => {
-  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
 })
 
 /** Boot the continuable stack with real JSONL session persistence. */

+ 1 - 1
packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts

@@ -47,7 +47,7 @@ const testToolSignal = new AbortController().signal
 
 const roots: string[] = []
 afterEach(() => {
-  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
 })
 
 async function setupWith(adapter: MockAdapter | GatedAdapter) {

+ 32 - 4
packages/workflow/workflow-worker-thread/src/host.ts

@@ -6,6 +6,7 @@
  * @module @deepseek-ai/dsh-workflow-worker-thread/host
  */
 
+import { tmpdir } from 'node:os'
 import { Worker } from 'node:worker_threads'
 import type { WorkerOptions } from 'node:worker_threads'
 import { fileURLToPath } from 'node:url'
@@ -28,18 +29,45 @@ interface ChildRecord {
   disposal?: Promise<void>
 }
 
+/**
+ * The scrubbed worker environment: no ambient credentials, no loader flags.
+ * Windows derives `os.tmpdir()` from `TMP`/`TEMP` and falls back to the
+ * literal relative path `undefined\temp` when the environment is empty, so
+ * tsx's transform cache would land in a cwd-relative `undefined/temp`
+ * directory; the host's real temp path (not a credential) is injected there.
+ * The unbuilt shape additionally forwards `TSX_TSCONFIG_PATH` for path
+ * resolution.
+ * @param platform - host platform; overridable so tests exercise both peer arms.
+ * @param tsconfigPath - the tsconfig pin to forward; only the unbuilt caller
+ *   passes one, so the built worker never observes the host's pin.
+ * @returns the scrubbed worker environment object.
+ */
+export function workerSpawnEnv(
+  platform: NodeJS.Platform = process.platform,
+  tsconfigPath?: string,
+): NodeJS.ProcessEnv {
+  const env: NodeJS.ProcessEnv = {}
+  if (platform === 'win32') {
+    const tmp = tmpdir()
+    env.TMP = tmp
+    env.TEMP = tmp
+  }
+  if (tsconfigPath !== undefined) env.TSX_TSCONFIG_PATH = tsconfigPath
+  return env
+}
+
 /**
  * Resolve a built worker bundle or an unbuilt bootstrap that installs both tsx
  * transforms inside the worker. Both shapes clear `execArgv` and the ambient
- * environment; the unbuilt shape forwards only `TSX_TSCONFIG_PATH` for path
- * resolution.
+ * environment (the worker only sees the platform temp path and, unbuilt,
+ * `TSX_TSCONFIG_PATH`).
  * @param init - the run payload, passed as `workerData`.
  * @returns the entry path or URL and the Worker options to spawn it with.
  */
 function resolveWorkerSpawn(init: WorkerInit): { entry: string | URL; options: WorkerOptions } {
   /* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/); the built-worker e2e exercises this shape for real */
   if (!import.meta.url.endsWith('.ts')) {
-    return { entry: fileURLToPath(new URL('./worker.cjs', import.meta.url)), options: { workerData: init, env: {}, execArgv: [] } }
+    return { entry: fileURLToPath(new URL('./worker.cjs', import.meta.url)), options: { workerData: init, env: workerSpawnEnv(), execArgv: [] } }
   }
   // Resolve tsx only for unbuilt consumers and install it before importing TS.
   const workerEntry = new URL('./worker.ts', import.meta.url)
@@ -56,7 +84,7 @@ function resolveWorkerSpawn(init: WorkerInit): { entry: string | URL; options: W
     entry: new URL(`data:text/javascript,${encodeURIComponent(bootstrap)}`),
     options: {
       workerData: init,
-      env: process.env.TSX_TSCONFIG_PATH === undefined ? {} : { TSX_TSCONFIG_PATH: process.env.TSX_TSCONFIG_PATH },
+      env: workerSpawnEnv(undefined, process.env.TSX_TSCONFIG_PATH),
       execArgv: [],
     },
   }

+ 38 - 6
packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts

@@ -1,4 +1,5 @@
 import { describe, expect, it, vi } from 'vitest'
+import { tmpdir } from 'node:os'
 import { fileURLToPath } from 'node:url'
 import type { Worker } from 'node:worker_threads'
 import { Context } from '@deepseek-ai/cordis'
@@ -9,6 +10,7 @@ import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRu
 import type { WorkflowMeta, WorkflowResult, WorkflowResultInfo, WorkflowRun, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow'
 import * as workerEngineModule from '../src/index.ts'
 import WorkerThreadWorkflowEngine, { type Config } from '../src/index.ts'
+import { workerSpawnEnv } from '../src/host.ts'
 import { HostToWorkerType, WorkerToHostType } from '../src/protocol.ts'
 import { SessionId } from '@deepseek-ai/dsh-session'
 
@@ -559,24 +561,51 @@ describe('dsh-workflow-worker-thread', () => {
       expect(result.value).toBe('fine')
     })
 
-    it('the worker spawns with an EMPTY environment: an escaped script finds no ambient credentials', async () => {
+    it('the worker spawns with a scrubbed environment: an escaped script finds no ambient credentials', async () => {
       const { ctx, parent } = await setup()
       // A canary in the HARNESS process's env: with an inherited environment
       // the escape below would read it back (exactly how DEEPSEEK_API_KEY
-      // would leak); env: {} in the spawn options is what keeps it out.
+      // would leak); the worker env keeps every ambient variable out. Windows
+      // additionally receives the host temp path (TMP/TEMP) so `os.tmpdir()`
+      // inside the worker resolves instead of degrading to a cwd-relative
+      // `undefined\temp` (tsx writes its transform cache there).
       process.env.WORKFLOW_ENV_CANARY = 'leak me'
+      // The unbuilt worker forwards TSX_TSCONFIG_PATH (a path pin, not a
+      // credential); clear it so this test observes the empty ambient case
+      // regardless of the parent's environment.
+      const tsconfigPath = process.env.TSX_TSCONFIG_PATH
+      delete process.env.TSX_TSCONFIG_PATH
       try {
         const result = await run(ctx, parent, scripted(`
           const proc = ${ESCAPE}
-          return { canary: proc.env.WORKFLOW_ENV_CANARY ?? null, keys: Object.keys(proc.env).length }
+          return { canary: proc.env.WORKFLOW_ENV_CANARY ?? null, keys: Object.keys(proc.env).sort() }
         `))
         expect(result.stopReason).toBe('completed')
-        expect(result.value).toEqual({ canary: null, keys: 0 })
+        const expectedKeys = process.platform === 'win32' ? ['TEMP', 'TMP'] : []
+        expect(result.value).toEqual({ canary: null, keys: expectedKeys })
       } finally {
+        if (tsconfigPath === undefined) delete process.env.TSX_TSCONFIG_PATH
+        else process.env.TSX_TSCONFIG_PATH = tsconfigPath
         delete process.env.WORKFLOW_ENV_CANARY
       }
     })
 
+    it('workerSpawnEnv injects the host temp path on win32 and leaves the POSIX peer empty', () => {
+      const tmp = tmpdir()
+      expect(workerSpawnEnv('win32')).toEqual({ TMP: tmp, TEMP: tmp })
+      expect(workerSpawnEnv('linux')).toEqual({})
+    })
+
+    it('workerSpawnEnv forwards TSX_TSCONFIG_PATH when the snapshot harness pins it', () => {
+      const tsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
+      expect(workerSpawnEnv('linux', tsconfig)).toEqual({ TSX_TSCONFIG_PATH: tsconfig })
+      expect(workerSpawnEnv('win32', tsconfig)).toEqual({
+        TMP: tmpdir(),
+        TEMP: tmpdir(),
+        TSX_TSCONFIG_PATH: tsconfig,
+      })
+    })
+
     it('the unbuilt worker forwards exactly TSX_TSCONFIG_PATH through the scrub: the paths-map pin survives, secrets do not', async () => {
       const { ctx, parent } = await setup()
       // The ACP snapshot harness runs the parent with its cwd OUTSIDE the
@@ -589,10 +618,13 @@ describe('dsh-workflow-worker-thread', () => {
       try {
         const result = await run(ctx, parent, scripted(`
           const proc = ${ESCAPE}
-          return { keys: Object.keys(proc.env), tsconfig: proc.env.TSX_TSCONFIG_PATH }
+          return { keys: Object.keys(proc.env).sort(), tsconfig: proc.env.TSX_TSCONFIG_PATH }
         `))
         expect(result.stopReason).toBe('completed')
-        expect(result.value).toEqual({ keys: ['TSX_TSCONFIG_PATH'], tsconfig })
+        const expectedKeys = process.platform === 'win32'
+          ? ['TEMP', 'TMP', 'TSX_TSCONFIG_PATH']
+          : ['TSX_TSCONFIG_PATH']
+        expect(result.value).toEqual({ keys: expectedKeys, tsconfig })
       } finally {
         delete process.env.TSX_TSCONFIG_PATH
         delete process.env.WORKFLOW_ENV_CANARY

+ 6 - 1
scripts/install-lefthook.spec.ts

@@ -16,6 +16,7 @@ import { tmpdir } from 'node:os'
 import { dirname, isAbsolute, join, resolve } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { afterEach, describe, expect, it } from 'vitest'
+import { removeFixtureSafely, unlinkFixtureLinks } from './test-fixture-cleanup.ts'
 
 const installer = fileURLToPath(new URL('./install-lefthook.mjs', import.meta.url))
 const pairingMergeDriver = 'scripts/merge-translation-pairing-driver.sh %O %A %B %P'
@@ -40,7 +41,7 @@ interface CommandResult {
 }
 
 afterEach(() => {
-  for (const fixture of fixtures.splice(0)) rmSync(fixture, { recursive: true, force: true })
+  for (const fixture of fixtures.splice(0)) removeFixtureSafely(fixture)
 })
 
 function commandResult(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv): CommandResult {
@@ -282,6 +283,10 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
     expect(gitResult(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'core.bare']).status).toBe(1)
 
     const mainHookBeforeRemoval = readFileSync(join(mainHooks, 'pre-commit'), 'utf8')
+    // Windows Git follows the fixture's MOUNT_POINT junctions into their real
+    // targets while removing a worktree; unlink them first so the removal
+    // cannot delete the repository's scripts/ or tsx package.
+    unlinkFixtureLinks(fixture.linked)
     git(fixture, fixture.main, ['worktree', 'remove', '--force', fixture.linked])
     expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBeforeRemoval)
     expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n')

+ 49 - 0
scripts/test-fixture-cleanup.ts

@@ -0,0 +1,49 @@
+/**
+ * Junction-safe fixture cleanup for Windows. Test fixtures junction the REAL
+ * `scripts/`, `node_modules`, and tsx package directories so installer probes
+ * resolve through them; Windows recursive deletion — both Node's `rmSync` and
+ * Git's `worktree remove` — follows MOUNT_POINT junctions into their targets
+ * and would delete the repository's own directories. POSIX `unlink`/`rm`
+ * already remove symlinks without following them, so the walk is a no-op
+ * there.
+ */
+
+import { lstatSync, readdirSync, rmSync, unlinkSync } from 'node:fs'
+import { join } from 'node:path'
+
+/**
+ * Recursively unlink every symbolic link (junction) under `path`.
+ * @param path - the fixture tree whose reparse points are unlinked.
+ */
+export function unlinkFixtureLinks(path: string): void {
+  const visit = (entry: string): void => {
+    let stat: ReturnType<typeof lstatSync>
+    try {
+      stat = lstatSync(entry)
+    } catch (error) {
+      if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
+      throw error
+    }
+    if (stat.isSymbolicLink() || !stat.isDirectory()) {
+      if (stat.isSymbolicLink()) unlinkSync(entry)
+      return
+    }
+    for (const child of readdirSync(entry)) visit(join(entry, child))
+  }
+  visit(path)
+}
+
+/**
+ * Remove one fixture tree after its junctions are unlinked (see
+ * {@link unlinkFixtureLinks}). Retries the removal: Windows releases child
+ * process and antivirus file handles asynchronously, and an unretried
+ * `rmSync` fails immediately with EPERM under load. A 10-second retry window
+ * (50 attempts × 200 ms) covers the failover pool's slow handle release;
+ * release is one-shot (a terminated child's handles drain, not reacquired),
+ * so a bounded window suffices and never pins afterEach cleanup.
+ * @param path - the fixture tree to remove.
+ */
+export function removeFixtureSafely(path: string): void {
+  unlinkFixtureLinks(path)
+  rmSync(path, { recursive: true, force: true, maxRetries: 50, retryDelay: 200 })
+}

+ 10 - 2
scripts/translation-pairing-merge.spec.ts

@@ -1,7 +1,14 @@
 /** Integration coverage for automatic and explicit pairing-record conflict resolution. */
 
 import { execFileSync, spawnSync } from 'node:child_process'
-import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
+import {
+  chmodSync,
+  mkdtempSync,
+  mkdirSync,
+  readFileSync,
+  symlinkSync,
+  writeFileSync,
+} from 'node:fs'
 import { tmpdir } from 'node:os'
 import { delimiter, dirname, join } from 'node:path'
 import { fileURLToPath } from 'node:url'
@@ -15,6 +22,7 @@ import {
   renderTranslationPairingRecord,
   translationPairPaths,
 } from './translation-pairing-record.ts'
+import { removeFixtureSafely } from './test-fixture-cleanup.ts'
 
 const driver = fileURLToPath(new URL('./merge-translation-pairing.ts', import.meta.url))
 const driverLauncher = fileURLToPath(new URL('./merge-translation-pairing-driver.sh', import.meta.url))
@@ -28,7 +36,7 @@ interface Fixture {
 }
 
 afterEach(() => {
-  for (const fixture of fixtures.splice(0)) rmSync(fixture, { recursive: true, force: true })
+  for (const fixture of fixtures.splice(0)) removeFixtureSafely(fixture)
 })
 
 function git(fixture: Fixture, args: string[]): string {