Kaynağa Gözat

test: remove dsh-* temp dirs created by unit tests at teardown

Spec files that create /tmp/dsh-* directories via mkdtemp now track and
delete them in afterEach/afterAll; module-scope fixture dirs (executor
spill dirs) are removed in afterAll. The file list came from the
observed-residue inventory on the self-hosted CI host: only specs whose
dirs actually accumulated were leak sources (issue #3134), superseding
the kept-but-unmerged CI sweep branch per the #3233 review decision.

Product per-process spill roots (dsh-subprocess-local spawn,
dsh-spill-local store) register a process-exit handler that removes the
memoized dir, so processes that used the spawn/spill path clean up on
normal exit. A SIGKILLed process cannot run in-process teardown; the
machine-side timer remains the backstop for that path.

Agent Note: .agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md
Chinesezjc 1 hafta önce
ebeveyn
işleme
0364343a7e
39 değiştirilmiş dosya ile 461 ekleme ve 73 silme
  1. 6 0
      .agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml
  2. 39 0
      .agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md
  3. 39 0
      .agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md
  4. 1 0
      apps/web/tests/agent-preset-authoring.e2e.ts
  5. 2 1
      apps/web/tests/agent-preset-selection.e2e.ts
  6. 11 1
      packages/api/session-controller/tests/agent.host.spec.ts
  7. 12 2
      packages/api/session-controller/tests/session-presets.host.spec.ts
  8. 6 1
      packages/api/workspace-controller/tests/workspace-controller.host.spec.ts
  9. 12 3
      packages/boot/app-boot/tests/app-boot.spec.ts
  10. 12 3
      packages/boot/app-boot/tests/config-dump.spec.ts
  11. 10 2
      packages/boot/app-boot/tests/config-reload.spec.ts
  12. 12 1
      packages/boot/app-boot/tests/hmr-config.spec.ts
  13. 11 2
      packages/boot/app-boot/tests/profile.spec.ts
  14. 12 3
      packages/boot/app-boot/tests/user-patches.spec.ts
  15. 6 1
      packages/boot/cmdline/tests/cmdline.spec.ts
  16. 6 1
      packages/bundle/headless/tests/startup.spec.ts
  17. 6 1
      packages/bundle/web-app/tests/startup.spec.ts
  18. 25 2
      packages/llm/llm-deepseek/tests/file-store.spec.ts
  19. 15 2
      packages/llm/llm-deepseek/tests/upload-index.spec.ts
  20. 13 3
      packages/preset/agent-presets/tests/authoring.spec.ts
  21. 11 1
      packages/preset/agent-presets/tests/composition-inventory.spec.ts
  22. 19 2
      packages/preset/agent-presets/tests/discovery.spec.ts
  23. 9 2
      packages/preset/agent-presets/tests/metadata.spec.ts
  24. 13 1
      packages/preset/agent-presets/tests/mount.spec.ts
  25. 13 2
      packages/preset/agent-presets/tests/remote.spec.ts
  26. 10 2
      packages/preset/agent-presets/tests/settings.spec.ts
  27. 6 3
      packages/preset/agent-presets/tests/shipped-root.spec.ts
  28. 8 2
      packages/preset/agent-presets/tests/user-root.spec.ts
  29. 16 3
      packages/sandbox/sandbox-local/tests/local.spec.ts
  30. 15 8
      packages/sandbox/sandbox/tests/roots.spec.ts
  31. 6 2
      packages/shell/bash-local/tests/executor.spec.ts
  32. 16 2
      packages/shell/pwsh-local/tests/executor.spec.ts
  33. 10 2
      packages/skill/skill-filesystem/tests/skill-filesystem-watcher.spec.ts
  34. 10 2
      packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts
  35. 11 3
      packages/skill/tool-skill/tests/tool-skill.spec.ts
  36. 8 1
      packages/spill/spill-local/src/store.ts
  37. 8 1
      packages/subprocess/subprocess-local/src/spawn.ts
  38. 6 2
      packages/subprocess/subprocess-local/tests/spawn.spec.ts
  39. 10 3
      scripts/coverage-partitions.spec.ts

+ 6 - 0
.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.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/process/2026-08-28-test-temp-dir-self-cleanup.md
+2026-08-28-test-temp-dir-self-cleanup.md: e8e0c5e2db716ff9a63ef55ae1af07a66eaf28a0
+2026-08-28-test-temp-dir-self-cleanup.zh.md: cdc0dfa2b6a00fc11703743c51326238bf644fb1

+ 39 - 0
.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md

@@ -0,0 +1,39 @@
+# Agent Note: Unit tests remove the dsh-* temp dirs they create
+
+Status: implemented
+
+English | [中文](2026-08-28-test-temp-dir-self-cleanup.zh.md)
+
+## Problem
+
+Test processes create `/tmp/dsh-*` directories with `mkdtemp(join(tmpdir(), 'dsh-*'))` and leave them behind. On the self-hosted Linux CI host (32 runner instances sharing one `/tmp`) the residue exhausted the root partition's inode capacity twice (issue #3134, 2026-08-13 and 2026-08-26). The machine-side `dsh-tmp-sweep` timer and the CI lane sweep (kept, unmerged, on branch `fix/ci-tmp-residue-cleanup`) remove residue after the fact but leave the producing defect in place. Human review of #3233 (2026-08-28) rejected the sweep: unit tests must clean up the directories they create instead.
+
+## Decision
+
+Retrofit removal of every `dsh-*` temp dir a spec file creates, at the owning test's teardown:
+
+- Spec files that created dirs without removing any now track each created root in a module-level list and delete the list in `afterEach`/`afterAll` (`rm`/`rmSync` with `recursive: true, force: true`), the convention already used across the session packages. Root-creating helpers (`tmp()`, `tempDir()`, `fakeLauncher()`, harness functions) register the root at creation, so every caller is covered at one point.
+- Module-scope fixture dirs shared by a whole file (executor spill dirs) are removed in `afterAll` after the last test.
+- The file list came from the observed-residue inventory on the CI host (a template histogram of current `/tmp/dsh-*` dirs): only spec files whose dirs actually appeared were leak sources. Files that already remove their dirs (agent-team, tool-subagent, list-children, hooks coverage cases) were confirmed clean on the normal-exit path and left unchanged.
+- Product per-process spill roots (`privateSpillDir` in `dsh-subprocess-local/spawn`, `privateRoot` in `dsh-spill-local/store`) register a `process.once('exit')` handler that removes the memoized directory, so every process that used the spawn/spill path cleans up on normal exit.
+
+## Verification
+
+- Targeted local run of every changed spec (32 files, 700 tests) passed, including the suites that exercise the changed product sources.
+- CI runs the changed specs on the Linux and Windows coverage lanes; after a full green run, the fixed files' residue templates (observed at up to ~5,000 dirs per two hours each, e.g. `dsh-profile-`, `dsh-app-boot-`, `dsh-presets-*`, `dsh-upload-index-`) should no longer appear in fresh `/tmp` residue on the CI host.
+
+## Alternatives considered
+
+### Keep the sweep-only approach (rejected in review)
+
+Sweep steps and timers delete residue after it exists; they do not stop local runs from accumulating, and a machine sweep cannot distinguish a dead run's residue from a live one's. The reviewer decision was per-test cleanup, implemented here for the normal-exit path.
+
+### Introduce a shared temp-dir helper package
+
+Not chosen: the files that leak each create roots through their own small helpers, and tracking them at those helpers is a per-file one-point change. A new test-support package would add a dependency without reducing the per-file audit.
+
+## Consequences
+
+- Bought: on normal completion — including failed tests — a spec's `dsh-*` dirs are removed at teardown, and per-process spill roots are removed when their process exits normally.
+- Cost: a process killed with SIGKILL (a cancelled run, a timeout kill) cannot run any in-process teardown; its in-flight residue remains. The machine-side timer stays as the backstop for that path.
+- Cost: dirs created by a spawned child are covered only when the test knows their paths; product-owned per-process roots are covered by the exit handler in the process that created them.

+ 39 - 0
.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md

@@ -0,0 +1,39 @@
+# Agent Note:单测删除自己创建的 dsh-* 临时目录
+
+Status: implemented
+
+[English](2026-08-28-test-temp-dir-self-cleanup.md) | 中文
+
+## Problem
+
+测试进程用 `mkdtemp(join(tmpdir(), 'dsh-*'))` 创建 `/tmp/dsh-*` 目录后不清理。在自托管 Linux CI 主机上(32 个 runner 实例共享一个 `/tmp`),残留两次耗尽根分区 inode(issue #3134,2026-08-13 与 2026-08-26)。机器侧 `dsh-tmp-sweep` timer 与 CI lane sweep(保留未合并,在分支 `fix/ci-tmp-residue-cleanup` 上)都是事后删除残留,未修掉产生残留的缺陷本体。人类 review #3233(2026-08-28)否决了 sweep:单测应改为自己清理创建的目录。
+
+## Decision
+
+为 spec 文件创建的每个 `dsh-*` 临时目录补上删除路径,挂在所属测试的 teardown 上:
+
+- 创建目录但从不删除的 spec 文件,现在把每个创建的 root 记入模块级列表,并在 `afterEach`/`afterAll` 里删除(`rm`/`rmSync` 带 `recursive: true, force: true`)——与 session 包既有的 `roots.splice(0)` 约定一致。创建 root 的 helper(`tmp()`、`tempDir()`、`fakeLauncher()`、harness 函数)在创建处登记,一个点覆盖全部调用方。
+- 整文件共享的模块级 fixture 目录(executor spill 目录)在最后一个测试之后的 `afterAll` 里删除。
+- 目标文件清单来自 CI 主机上的残留实测清单(当前 `/tmp/dsh-*` 目录的模板直方图):只有目录确实出现在残留里的 spec 文件才是泄漏源。已有删除逻辑的文件(agent-team、tool-subagent、list-children、hooks coverage cases)确认在正常结束路径上本来干净,不改。
+- 产品侧每进程 spill root(`dsh-subprocess-local/spawn` 的 `privateSpillDir`、`dsh-spill-local/store` 的 `privateRoot`)注册 `process.once('exit')` handler,在进程正常退出时删除记忆化的目录——凡走过 spawn/spill 路径的进程都会在正常结束时清理。
+
+## Verification
+
+- 本地定向跑过全部改动 spec(32 个文件、700 个测试)通过,含直接使用改动后产品源码的套件。
+- CI 在 Linux 与 Windows coverage lane 跑改动 spec;一次全绿后,被修文件的残留模板(实测每两小时最多各约 5,000 个目录,如 `dsh-profile-`、`dsh-app-boot-`、`dsh-presets-*`、`dsh-upload-index-`)应不再出现在 CI 主机的新鲜 `/tmp` 残留里。
+
+## Alternatives considered
+
+### 保留纯 sweep 方案(review 否决)
+
+Sweep 步骤与 timer 只删已存在的残留;本地运行仍会累积,机器 sweep 也区分不了已死 run 的残留与存活 run 的目录。review 的决定是逐测试清理,本实现覆盖正常结束路径。
+
+### 引入共享临时目录 helper 包
+
+未选:泄漏文件各自通过自己的小 helper 创建 root,在那些 helper 处登记是每个文件单点改动;新增 test-support 包只会增加依赖,不减少逐文件审计量。
+
+## Consequences
+
+- 收益:正常结束(含测试失败)时,spec 的 `dsh-*` 目录在 teardown 删除;每进程 spill root 在其进程正常退出时删除。
+- 代价:被 SIGKILL 的进程(run 被取消、超时被杀)无法运行任何进程内 teardown,飞行中的残留仍在——机器侧 timer 继续兜底该路径。
+- 代价:子进程创建的目录只有在测试知道其路径时才被覆盖;产品自有每进程 root 由创建它的进程的 exit handler 覆盖。

+ 1 - 0
apps/web/tests/agent-preset-authoring.e2e.ts

@@ -76,6 +76,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
   afterAll(async () => {
     await browser?.close()
     await scaffold?.close()
+    await rm(userRoot, { recursive: true, force: true })
   })
 
   it('offers the roster with copy as the only way to create', async () => {

+ 2 - 1
apps/web/tests/agent-preset-selection.e2e.ts

@@ -9,7 +9,7 @@
 //
 // Zero model calls: no replay fixture mounts, so a stray stream fails loud.
 import { fileURLToPath } from 'node:url'
-import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import type { Browser, Page } from 'playwright'
@@ -232,6 +232,7 @@ describe('web e2e: agent-preset selection', () => {
   afterAll(async () => {
     await browser?.close()
     await scaffold?.close()
+    await rm(presetRoot, { recursive: true, force: true })
   })
 
   it('offers the chip on the new-session screen, beside the workspace picker', async () => {

+ 11 - 1
packages/api/session-controller/tests/agent.host.spec.ts

@@ -1,4 +1,4 @@
-import { mkdtempSync, writeFileSync } from 'node:fs'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
@@ -22,8 +22,12 @@ import { installSessionReadTestServices, testSessionPersistence } from './test-r
 
 const roots: Context[] = []
 
+/** Session cwd roots created per test, removed after their context settles. */
+const tempDirs: string[] = []
+
 afterEach(async () => {
   await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose()))
+  for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
 })
 
 async function harness(): Promise<{ ctx: Context; agents: ApiSessionAgentController }> {
@@ -297,6 +301,7 @@ describe('ApiSession create or adoption', () => {
   it('shares one in-flight creation between concurrent callers', async () => {
     const { ctx, agents } = await harness()
     const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-concurrent-'))
+    tempDirs.push(cwd)
     const meta = header('concurrent-create', cwd)
     const created = unpublishedAgent(ctx, meta)
     let release!: () => void
@@ -317,6 +322,7 @@ describe('ApiSession create or adoption', () => {
   it('accepts a raced ordinary creation and rejects a raced attached child', async () => {
     const ordinary = await harness()
     const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-create-'))
+    tempDirs.push(cwd)
     const ordinaryMeta = header('create-race', cwd)
     const winner = agent(ordinary.ctx, ordinaryMeta)
     vi.spyOn(ordinary.ctx.agents, 'create').mockImplementation(async () => {
@@ -328,6 +334,7 @@ describe('ApiSession create or adoption', () => {
 
     const child = await harness()
     const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-child-'))
+    tempDirs.push(childCwd)
     const childId = SessionId('create-child-race')
     vi.spyOn(child.ctx.agents, 'create').mockImplementation(async () => {
       child.ctx.sessions.create(childId, {
@@ -342,6 +349,7 @@ describe('ApiSession create or adoption', () => {
   it('validates ownership and cwd on the Agent returned by creation', async () => {
     const child = await harness()
     const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-returned-child-'))
+    tempDirs.push(childCwd)
     const childMeta = {
       ...header('returned-child', childCwd),
       parentSession: SessionId('parent'),
@@ -357,6 +365,7 @@ describe('ApiSession create or adoption', () => {
 
     const wrong = await harness()
     const requestedCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-wrong-cwd-'))
+    tempDirs.push(requestedCwd)
     const wrongAgent = unpublishedAgent(wrong.ctx, header('wrong-returned-cwd', '/other'))
     vi.spyOn(wrong.ctx.agents, 'create').mockResolvedValue({
       agent: wrongAgent,
@@ -437,6 +446,7 @@ describe('ApiSession create or adoption', () => {
   it('surfaces directory creation failure and rejects setup without a scoped Agent', async () => {
     const { agents } = await harness()
     const parent = mkdtempSync(join(tmpdir(), 'dsh-session-controller-file-'))
+    tempDirs.push(parent)
     const file = join(parent, 'file')
     writeFileSync(file, 'not a directory')
     await expect(agents.ensureSession(SessionId('mkdir-failure'), join(file, 'child'), false))

+ 12 - 2
packages/api/session-controller/tests/session-presets.host.spec.ts

@@ -1,6 +1,6 @@
 /** Session creation and adoption rules for Agent preset identity. */
 
-import { mkdtempSync, realpathSync } from 'node:fs'
+import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
@@ -10,9 +10,17 @@ import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import type { Session } from '@deepseek-ai/dsh-session'
 import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
-import { describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it } from 'vitest'
 import { createSessionTestRemote } from './test-remote.ts'
 
+/** Booted contexts and their temp roots, torn down after each test. */
+const contexts: Context[] = []
+const tempDirs: string[] = []
+afterEach(async () => {
+  await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
+  for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
+})
+
 function stubAgent(session: Session): Agent {
   return { id: session.id, session, status: 'idle' } as unknown as Agent
 }
@@ -42,7 +50,9 @@ function roster(ids: readonly string[]): unknown {
 
 async function harness(presets?: readonly string[]) {
   const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-')))
+  tempDirs.push(cwd)
   const ctx = new Context()
+  contexts.push(ctx)
   await ctx.plugin(SessionStore)
   await ctx.plugin(AgentRegistry)
   if (presets !== undefined) {

+ 6 - 1
packages/api/workspace-controller/tests/workspace-controller.host.spec.ts

@@ -1,4 +1,4 @@
-import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs'
+import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -22,8 +22,12 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
 
 const roots: Context[] = []
 
+/** Workspace roots created per test, removed after their context settles. */
+const tempDirs: string[] = []
+
 afterEach(async () => {
   await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose()))
+  for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
 })
 
 interface Deferred<T> {
@@ -39,6 +43,7 @@ function deferred<T>(): Deferred<T> {
 
 async function harness() {
   const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-workspace-controller-')))
+  tempDirs.push(root)
   const ctx = new Context()
   roots.push(ctx)
   await ctx.plugin(SessionStore)

+ 12 - 3
packages/boot/app-boot/tests/app-boot.spec.ts

@@ -1,8 +1,8 @@
-import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
+import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join, resolve, sep } from 'node:path'
 import { pathToFileURL } from 'node:url'
-import { describe, expect, it, vi } from 'vitest'
+import { afterAll, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
 import {
@@ -13,7 +13,16 @@ import {
 
 const NAME = 'dsh-test-bin'
 
-const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-app-boot-'))
+const tempRoots: string[] = []
+afterAll(() => {
+  for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+const tmp = (): string => {
+  const dir = mkdtempSync(join(tmpdir(), 'dsh-app-boot-'))
+  tempRoots.push(dir)
+  return dir
+}
 
 describe('resolveConfigPath', () => {
   it('resolves relative to the given cwd outside replay mode', () => {

+ 12 - 3
packages/boot/app-boot/tests/config-dump.spec.ts

@@ -7,18 +7,27 @@
  * shared overlay whose row exists only on another surface.
  */
 
-import { mkdtempSync, writeFileSync } from 'node:fs'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
-import { describe, expect, it, vi } from 'vitest'
+import { afterAll, describe, expect, it, vi } from 'vitest'
 import * as yaml from 'js-yaml'
 import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
 import { loadOverlayPatches, renderConfigDump } from '../src/index.ts'
 
 const NAME = 'dsh-test-bin'
 
-const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-config-dump-'))
+const tempRoots: string[] = []
+afterAll(() => {
+  for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+const tmp = (): string => {
+  const dir = mkdtempSync(join(tmpdir(), 'dsh-config-dump-'))
+  tempRoots.push(dir)
+  return dir
+}
 
 function writeBase(dir: string): string {
   const base = join(dir, 'base.yml')

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

@@ -4,10 +4,10 @@
  * previous generation has been retained or restored.
  */
 
-import { mkdtempSync, writeFileSync } from 'node:fs'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { describe, expect, it } from 'vitest'
+import { afterAll, describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import type { Include } from '@deepseek-ai/cordis-plugin-include'
 import { boot } from '../src/index.ts'
@@ -16,6 +16,11 @@ const NAME = 'dsh-test-bin'
 
 const NOOP_PLUGIN = 'export const name = "noop"\nexport function apply() {}\n'
 
+const tempRoots: string[] = []
+afterAll(() => {
+  for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
 interface TreeFixture {
   ctx: Context
   dir: string
@@ -24,6 +29,7 @@ interface TreeFixture {
 
 async function bootTree(configBody: string, files: Record<string, string> = {}): Promise<TreeFixture> {
   const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-'))
+  tempRoots.push(dir)
   writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
   for (const [name, content] of Object.entries(files)) writeFileSync(join(dir, name), content)
   writeFileSync(join(dir, 'cordis.yml'), configBody)
@@ -282,6 +288,7 @@ describe('loader tree replacement', () => {
 describe('include refresh with overlay patches', () => {
   it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => {
     const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-'))
+    tempRoots.push(dir)
     writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
     writeFileSync(join(dir, 'base.yml'), '- id: noop\n  name: ./noop.mjs\n  config:\n    value: base\n')
     writeFileSync(join(dir, 'cordis.yml'), [
@@ -347,6 +354,7 @@ describe('include patches layered over one base', () => {
     // must therefore be able to reach a row an earlier layer inserted, or
     // bundle-only rows would be invisible to the user's patch layer.
     const dir = mkdtempSync(join(tmpdir(), 'dsh-config-layered-'))
+    tempRoots.push(dir)
     writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
     writeFileSync(join(dir, 'base.yml'), '- id: shared\n  name: ./noop.mjs\n  config:\n    value: base\n')
     writeFileSync(join(dir, 'cordis.yml'), [

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

@@ -7,7 +7,10 @@ import { Context } from '@deepseek-ai/cordis'
 import Hmr from '@deepseek-ai/cordis-plugin-hmr'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
 import Timer from '@deepseek-ai/cordis-plugin-timer'
-import { describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+/** Every per-test tree root, removed once the booted watcher has been disposed. */
+const hmrRoots: string[] = []
 
 async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): Promise<Context> {
   const ctx = new Context()
@@ -32,6 +35,10 @@ async function eventually(test: () => boolean, message: string): Promise<void> {
 }
 
 describe('HMR exact config paths', () => {
+  afterEach(() => {
+    for (const root of hmrRoots.splice(0)) rmSync(root, { recursive: true, force: true })
+  })
+
   it('observes module changes when its watch base is a filesystem alias', { timeout: 30_000 }, async () => {
     const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-module-canonical-'))
     const alias = `${target}-alias`
@@ -85,6 +92,7 @@ describe('HMR exact config paths', () => {
 
   it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => {
     const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
+    hmrRoots.push(dir)
     const filename = join(dir, 'plugins.yml')
     const ctx = await bootHmr(dir)
     const observed: string[] = []
@@ -111,6 +119,7 @@ describe('HMR exact config paths', () => {
 
   it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => {
     const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
+    hmrRoots.push(root)
     const dir = join(root, 'later')
     const filename = join(dir, 'plugins.yml')
     const ctx = await bootHmr(root)
@@ -129,6 +138,7 @@ describe('HMR exact config paths', () => {
 
   it('serializes refreshes and waits for them during disposal', { timeout: 20_000 }, async () => {
     const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
+    hmrRoots.push(dir)
     const filename = join(dir, 'plugins.yml')
     writeFileSync(filename, 'one')
     const ctx = await bootHmr(dir)
@@ -170,6 +180,7 @@ describe('HMR exact config paths', () => {
 
   it('normalizes refresh failures and broadcasts them without escaping the watcher', { timeout: 20_000 }, async () => {
     const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
+    hmrRoots.push(dir)
     const filename = join(dir, 'plugins.yml')
     const ctx = await bootHmr(dir)
     const failure = Promise.withResolvers<{ filename: string; error: Error }>()

+ 11 - 2
packages/boot/app-boot/tests/profile.spec.ts

@@ -11,7 +11,7 @@ import {
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
-import { describe, expect, it } from 'vitest'
+import { afterAll, describe, expect, it } from 'vitest'
 import {
   composeEntries,
   healProfilesModuleFallback,
@@ -26,7 +26,16 @@ import {
   type Profile,
 } from '../src/index.ts'
 
-const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-profile-'))
+const tempRoots: string[] = []
+afterAll(() => {
+  for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+const tmp = (): string => {
+  const dir = mkdtempSync(join(tmpdir(), 'dsh-profile-'))
+  tempRoots.push(dir)
+  return dir
+}
 
 /** Stage a fake installed app: package.json with deps and a node_modules holding bundles. */
 function stageInstallation(

+ 12 - 3
packages/boot/app-boot/tests/user-patches.spec.ts

@@ -4,11 +4,11 @@
  * a real Loader tree, kept live through transactional HMR.
  */
 
-import { mkdirSync, mkdtempSync, unlinkSync, writeFileSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterAll, afterEach, describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import Hmr from '@deepseek-ai/cordis-plugin-hmr'
 import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
@@ -23,7 +23,16 @@ import {
 
 const NAME = 'dsh-test-bin'
 
-const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-user-patches-'))
+const tempRoots: string[] = []
+afterAll(() => {
+  for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+const tmp = (): string => {
+  const dir = mkdtempSync(join(tmpdir(), 'dsh-user-patches-'))
+  tempRoots.push(dir)
+  return dir
+}
 
 async function eventually(test: () => boolean, message: string): Promise<void> {
   const deadline = Date.now() + 10_000

+ 6 - 1
packages/boot/cmdline/tests/cmdline.spec.ts

@@ -4,7 +4,7 @@
  * active, then resolves that row's config against its injection-ready context.
  */
 
-import { mkdtempSync, writeFileSync } from 'node:fs'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { EventEmitter } from 'node:events'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
@@ -34,6 +34,9 @@ interface Fixture {
 
 const disposers: (() => Promise<void>)[] = []
 
+/** Fixture tree roots, removed after their booted tree has been disposed. */
+const tempDirs: string[] = []
+
 const readyApp: AppReady = {
   onReady(listener) {
     listener()
@@ -59,6 +62,7 @@ function controlledAppReady(): { service: AppReady; commit(): void } {
 
 afterEach(async () => {
   for (const dispose of disposers.splice(0)) await dispose()
+  for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
   internals.stdin = process.stdin
   internals.stdout = process.stdout
   internals.stderr = process.stderr
@@ -103,6 +107,7 @@ async function bootFixture(
   options: { objectInject?: boolean; withoutProvider?: boolean } = {},
 ): Promise<Fixture> {
   const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-'))
+  tempDirs.push(dir)
   const observed: Observed = { exits: [], out: '' }
   writeFileSync(join(dir, 'reader.mjs'), `
 export const name = 'reader'

+ 6 - 1
packages/bundle/headless/tests/startup.spec.ts

@@ -4,7 +4,7 @@
  * the consumer pending.
  */
 
-import { mkdtempSync, writeFileSync } from 'node:fs'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
@@ -24,8 +24,12 @@ interface Observed {
 
 const disposers: (() => Promise<void>)[] = []
 
+/** Fixture tree roots, removed after their booted tree has been disposed. */
+const tempDirs: string[] = []
+
 afterEach(async () => {
   for (const dispose of disposers.splice(0)) await dispose()
+  for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
   internals.stdout = process.stdout
   internals.stderr = process.stderr
 })
@@ -37,6 +41,7 @@ afterEach(async () => {
  */
 async function bootStartup(args: string[]): Promise<{ task: HeadlessStartupValues | undefined; observed: Observed }> {
   const dir = mkdtempSync(join(tmpdir(), 'dsh-headless-startup-'))
+  tempDirs.push(dir)
   const observed: Observed = { exits: [], out: '' }
   writeFileSync(join(dir, 'row.mjs'), 'export function apply(_ctx, config) { globalThis.__headlessStartupObserved.runnerConfig = config }\n')
   // Loader imports through Node's resolver, so this fixture delegates to the

+ 6 - 1
packages/bundle/web-app/tests/startup.spec.ts

@@ -3,7 +3,7 @@
  * releases a consumer whose config reads `ctx.webStartup` directly.
  */
 
-import { mkdtempSync, writeFileSync } from 'node:fs'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
@@ -23,8 +23,12 @@ interface Observed {
 
 const disposers: (() => Promise<void>)[] = []
 
+/** Fixture tree roots, removed after their booted tree has been disposed. */
+const tempDirs: string[] = []
+
 afterEach(async () => {
   for (const dispose of disposers.splice(0)) await dispose()
+  for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
   internals.stdout = process.stdout
   internals.stderr = process.stderr
 })
@@ -39,6 +43,7 @@ async function bootProvider(args: string[]): Promise<{
   observed: Observed
 }> {
   const dir = mkdtempSync(join(tmpdir(), 'dsh-web-startup-'))
+  tempDirs.push(dir)
   const observed: Observed = { exits: [], out: '' }
   writeFileSync(join(dir, 'reader.mjs'), `
 export function apply(_ctx, config) { globalThis.__webStartupObserved.readerConfig = config }

+ 25 - 2
packages/llm/llm-deepseek/tests/file-store.spec.ts

@@ -1,7 +1,7 @@
-import { mkdtemp } from 'node:fs/promises'
+import { mkdtemp, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
 import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment'
 import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment'
 import { DeepSeekFileStore, MAX_CHAT_IMAGE_BYTES } from '../src/file-store.ts'
@@ -31,6 +31,12 @@ const CONNECTION = { baseURL: 'https://api.deepseek.com', apiKey: 'key' }
 const POLICY = { expiresAfterSeconds: 604_800, refreshMarginSeconds: 3_600, quotaCleanupBatch: 100 }
 const NOW = 1_700_000_000_000
 
+/** Every temp store root created by this file, removed after each test. */
+const roots: string[] = []
+afterEach(async () => {
+  for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
+})
+
 function requestUrl(input: string | URL | Request): string {
   if (typeof input === 'string') return input
   return input instanceof URL ? input.href : input.url
@@ -64,6 +70,7 @@ function uploadFetch(now: () => number = () => NOW) {
 describe('DeepSeekFileStore', () => {
   it('singleflights the first upload and reuses the durable mapping across store instances', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
     const remote = uploadFetch()
     const first = new DeepSeekFileStore({ index, now: () => NOW, fetch: remote.fetchImpl })
@@ -84,6 +91,7 @@ describe('DeepSeekFileStore', () => {
 
   it('keeps a shared upload alive while another waiter remains', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
     let complete: ((response: Response) => void) | undefined
     let uploadSignal: AbortSignal | undefined
@@ -123,6 +131,7 @@ describe('DeepSeekFileStore', () => {
 
   it('aborts the shared upload after its only waiter cancels', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
     let uploadSignal: AbortSignal | undefined
     const fetchImpl = vi.fn((_url: string | URL | Request, init?: RequestInit) => {
@@ -149,6 +158,7 @@ describe('DeepSeekFileStore', () => {
 
   it('normalizes a non-Error cancellation reason', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const fetchImpl = vi.fn((_url: string | URL | Request, init?: RequestInit) => (
       new Promise<Response>((_resolve, reject) => {
         init?.signal?.addEventListener('abort', () => {
@@ -176,6 +186,7 @@ describe('DeepSeekFileStore', () => {
 
   it('starts a fresh upload while the cancelled transport is settling', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     let requests = 0
     const fetchImpl = vi.fn((_url: string | URL | Request, init?: RequestInit) => {
       requests += 1
@@ -222,6 +233,7 @@ describe('DeepSeekFileStore', () => {
 
   it('does not persist an upload whose response is missing and retries on the next request', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
     const good = uploadFetch()
     let first = true
@@ -242,6 +254,7 @@ describe('DeepSeekFileStore', () => {
 
   it('rejects an upload response whose byte count differs from the request version', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const fetchImpl = vi.fn(() => Promise.resolve(new Response(JSON.stringify({
       id: 'file-api-wrong-size', object: 'file', bytes: 2, created_at: NOW / 1_000,
       filename: 'dsh-wrong.png', purpose: 'user_data',
@@ -262,6 +275,7 @@ describe('DeepSeekFileStore', () => {
     ['image/gif', 'gif'],
   ] as const)('uses the %s filename extension for uploads', async (mediaType, extension) => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const remote = uploadFetch()
     const store = new DeepSeekFileStore({
       index: new DeepSeekUploadIndex(join(dir, `${extension}.json`)),
@@ -279,6 +293,7 @@ describe('DeepSeekFileStore', () => {
 
   it('normalizes a non-Error failure from the durable upload index', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
     vi.spyOn(index, 'get').mockRejectedValue('index unavailable')
     const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: vi.fn() as typeof fetch })
@@ -291,6 +306,7 @@ describe('DeepSeekFileStore', () => {
 
   it('reuses local expires_at above the refresh margin and uploads again at the margin', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
     let now = NOW
     const remote = uploadFetch(() => now)
@@ -311,6 +327,7 @@ describe('DeepSeekFileStore', () => {
 
   it('releases an indexed file through DELETE and removes only that mapping', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
     const remote = uploadFetch()
     const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: remote.fetchImpl })
@@ -323,6 +340,7 @@ describe('DeepSeekFileStore', () => {
 
   it('removes a losing upload and keeps the winning durable mapping when duplicate cleanup fails', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
     vi.spyOn(index, 'commit').mockResolvedValue({
       accepted: false,
@@ -352,6 +370,7 @@ describe('DeepSeekFileStore', () => {
 
   it('reclaims one owned file after quota rejection and retries the upload once', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     let uploads = 0
     const fetchImpl = vi.fn((input: string | URL | Request, init?: RequestInit) => {
       if (init?.method === 'POST') {
@@ -394,6 +413,7 @@ describe('DeepSeekFileStore', () => {
 
   it('preserves a quota error when no harness-owned file can be reclaimed', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const fetchImpl = vi.fn((_input: string | URL | Request, init?: RequestInit) => {
       if (init?.method === 'POST') return Promise.resolve(new Response(JSON.stringify({
         error: { message: 'file count quota exceeded', code: 'file_quota' },
@@ -418,6 +438,7 @@ describe('DeepSeekFileStore', () => {
 
   it('finishes pagination before deleting cursor files during quota recovery', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const deleted = new Set<string>()
     const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
       const target = new URL(requestUrl(input))
@@ -456,6 +477,7 @@ describe('DeepSeekFileStore', () => {
 
   it('stops pagination when a page omits or repeats its cursor', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     for (const mode of ['missing', 'repeated'] as const) {
       let page = 0
       const fetchImpl = vi.fn((input: string | URL | Request, init?: RequestInit) => {
@@ -482,6 +504,7 @@ describe('DeepSeekFileStore', () => {
 
   it('releases every batch and clears the scoped upload index', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
     const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: vi.fn() as typeof fetch })
     const reclaim = vi.spyOn(store, 'reclaimOldestOwned')

+ 15 - 2
packages/llm/llm-deepseek/tests/upload-index.spec.ts

@@ -1,7 +1,7 @@
-import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it } from 'vitest'
 import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment'
 import { DeepSeekFileId } from '../src/file-id.ts'
 import { deepSeekFileScope, DeepSeekUploadIndex } from '../src/upload-index.ts'
@@ -9,6 +9,12 @@ import { deepSeekFileScope, DeepSeekUploadIndex } from '../src/upload-index.ts'
 const ATTACHMENT = AttachmentId(`sha256:${'a'.repeat(64)}`)
 const VARIANT = ImageVariantId(`sha256:${'b'.repeat(64)}`)
 
+/** Every temp index root created by this file, removed after each test. */
+const roots: string[] = []
+afterEach(async () => {
+  for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
+})
+
 describe('DeepSeekUploadIndex', () => {
   it('normalizes trailing endpoint slashes in the credential scope', () => {
     expect(deepSeekFileScope('https://api.deepseek.com///', 'key'))
@@ -17,6 +23,7 @@ describe('DeepSeekUploadIndex', () => {
 
   it('isolates API-key namespaces and reuses only records above the refresh margin', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-'))
+    roots.push(dir)
     const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
     const first = deepSeekFileScope('https://api.deepseek.com', 'first-key')
     const second = deepSeekFileScope('https://api.deepseek.com', 'second-key')
@@ -38,6 +45,7 @@ describe('DeepSeekUploadIndex', () => {
 
   it('keeps a reusable cross-process winner and removes only an exact generation', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-'))
+    roots.push(dir)
     const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
     const scope = deepSeekFileScope('https://api.deepseek.com', 'key')
     const first = {
@@ -56,6 +64,7 @@ describe('DeepSeekUploadIndex', () => {
 
   it('treats a corrupt upload cache as empty and repairs it on the next commit', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-'))
+    roots.push(dir)
     const path = join(dir, 'index.json')
     await writeFile(path, '{bad', 'utf8')
     const index = new DeepSeekUploadIndex(path)
@@ -128,6 +137,7 @@ describe('DeepSeekUploadIndex', () => {
     })}]}`,
   ])('treats an invalid persisted index as empty %#', async (text) => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-'))
+    roots.push(dir)
     const path = join(dir, 'index.json')
     await writeFile(path, text, 'utf8')
     const index = new DeepSeekUploadIndex(path)
@@ -138,6 +148,7 @@ describe('DeepSeekUploadIndex', () => {
 
   it('rejects duplicate persisted mappings as a corrupt cache', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-'))
+    roots.push(dir)
     const path = join(dir, 'index.json')
     const scope = deepSeekFileScope('https://api.deepseek.com', 'key')
     const record = {
@@ -151,6 +162,7 @@ describe('DeepSeekUploadIndex', () => {
 
   it('drops expired records on commit and clears only the selected namespace', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-'))
+    roots.push(dir)
     const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
     const first = deepSeekFileScope('https://api.deepseek.com', 'first')
     const second = deepSeekFileScope('https://api.deepseek.com', 'second')
@@ -171,6 +183,7 @@ describe('DeepSeekUploadIndex', () => {
 
   it('propagates non-cache filesystem read failures', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-'))
+    roots.push(dir)
     const path = join(dir, 'directory')
     await mkdir(path)
     const index = new DeepSeekUploadIndex(path)

+ 13 - 3
packages/preset/agent-presets/tests/authoring.spec.ts

@@ -6,7 +6,7 @@
  * stays read-only.
  */
 
-import { chmod, mkdtemp, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
+import { chmod, mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
 import { existsSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
@@ -15,7 +15,7 @@ import { Context } from '@deepseek-ai/cordis'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
 import Include from '@deepseek-ai/cordis-plugin-include'
 import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
-import { beforeEach, describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
 import AgentPresets, {
   COMPOSITION_FILE, copyComposition, METADATA_FILE, type Config,
 } from '@deepseek-ai/dsh-agent-presets'
@@ -23,6 +23,12 @@ import AgentPresets, {
 const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
 const VALID = '- id: tool-alpha\n  name: ../../plugins/contribute.js\n  config:\n    tool: alpha\n'
 
+/** Every temp root created by this file, removed after each test. */
+const roots: string[] = []
+afterEach(async () => {
+  for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
+})
+
 let ctx: Context
 let userRoot: string
 
@@ -49,6 +55,7 @@ async function seedPreset(
 
 beforeEach(async () => {
   userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-'))
+  roots.push(userRoot)
   ctx = new Context()
   ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
   await ctx.plugin(Loader)
@@ -201,6 +208,7 @@ describe('deleting a preset', () => {
 describe('a deployment with more than one user root', () => {
   it('refuses to delete a preset the writable root does not own', async () => {
     const second = await mkdtemp(join(tmpdir(), 'dsh-preset-second-'))
+    roots.push(second)
     await seedPreset(second, 'elsewhere')
     const layered = new Context()
     layered.baseUrl = pathToFileURL(FIXTURES).href + '/'
@@ -246,7 +254,9 @@ describe('a deployment with no writable root', () => {
 
 describe('a user root that does not exist yet', () => {
   it('is created by the first copy', async () => {
-    const absent = join(await mkdtemp(join(tmpdir(), 'dsh-preset-absent-')), 'nested', 'preset')
+    const absentRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-absent-'))
+    roots.push(absentRoot)
+    const absent = join(absentRoot, 'nested', 'preset')
     const fresh = new Context()
     fresh.baseUrl = pathToFileURL(FIXTURES).href + '/'
     await fresh.plugin(Loader)

+ 11 - 1
packages/preset/agent-presets/tests/composition-inventory.spec.ts

@@ -5,7 +5,7 @@
  * read reported broken by reason instead of dropped.
  */
 
-import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
 import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -33,6 +33,9 @@ const VALID = '- id: prompt\n  name: \'@deepseek-ai/dsh-system-prompt\'\n'
 
 const contexts: Context[] = []
 
+/** Every temp root created by this file, removed after its contexts settle. */
+const roots: string[] = []
+
 /** A Loader-context evaluator over an empty scope, enough for literal gates. */
 const evaluateExpression = (expression: string): unknown => evaluate({}, expression)
 /** An evaluator that refuses every expression, leaving rows conditional. */
@@ -41,6 +44,7 @@ const refuseExpression = (): never => { throw new Error('no loader context') }
 afterEach(async () => {
   vi.restoreAllMocks()
   await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
+  for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
 })
 
 async function harness(roster: Config): Promise<Context> {
@@ -63,6 +67,7 @@ async function harness(roster: Config): Promise<Context> {
 describe('fileComposition', () => {
   it('flattens groups and keeps refused expressions conditional', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-composition-'))
+    roots.push(dir)
     const path = join(dir, COMPOSITION_FILE)
     await writeFile(path, [
       '- id: alpha',
@@ -124,6 +129,7 @@ describe('fileComposition', () => {
 
   it('evaluates decidable gates the way a mount would', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-composition-'))
+    roots.push(dir)
     const path = join(dir, COMPOSITION_FILE)
     await writeFile(path, [
       '- id: off',
@@ -144,6 +150,7 @@ describe('fileComposition', () => {
 
   it('answers broken for a file that stopped reading as a composition', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-composition-'))
+    roots.push(dir)
 
     const missing = await fileComposition(join(dir, COMPOSITION_FILE), refuseExpression)
     expect(missing).toHaveProperty('broken')
@@ -201,6 +208,7 @@ describe('mountedCompositionRows', () => {
 describe('AgentPresets.compositionInventory', () => {
   it('reads unmounted presets from their files, marking the default and metadata', async () => {
     const userRoot = await mkdtemp(join(tmpdir(), 'dsh-composition-roster-'))
+    roots.push(userRoot)
     await mkdir(join(userRoot, 'documented'))
     await writeFile(join(userRoot, 'documented', COMPOSITION_FILE), [
       VALID.trimEnd(),
@@ -287,6 +295,7 @@ describe('AgentPresets.compositionInventory', () => {
 
   it('prefers the standing mount over a file that broke after mounting', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-composition-volatile-'))
+    roots.push(root)
     await mkdir(join(root, 'volatile'))
     const plugin = join(FIXTURES, 'plugins', 'contribute.js')
     await writeFile(
@@ -344,6 +353,7 @@ describe('AgentPresets.compositionInventory', () => {
 
   it('keeps a broken preset on the inventory with its discovery reason', async () => {
     const userRoot = await mkdtemp(join(tmpdir(), 'dsh-composition-roster-'))
+    roots.push(userRoot)
     await mkdir(join(userRoot, 'damaged'))
     const ctx = await harness({
       default: 'minimal',

+ 19 - 2
packages/preset/agent-presets/tests/discovery.spec.ts

@@ -1,8 +1,8 @@
-import { mkdtemp, mkdir, symlink, writeFile } from 'node:fs/promises'
+import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
 import { fileURLToPath, pathToFileURL } from 'node:url'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { COMPOSITION_FILE, discoverPresets, scanRoot } from '@deepseek-ai/dsh-agent-presets'
 
 const fsHarness = vi.hoisted(() => ({
@@ -31,6 +31,12 @@ const HARNESS = new URL('.', import.meta.url).href
 const SYSTEM = { path: join(FIXTURES, 'system'), trust: 'system' as const }
 const USER = { path: join(FIXTURES, 'user'), trust: 'user' as const }
 
+/** Every temp root created by this file, removed after each test. */
+const roots: string[] = []
+afterEach(async () => {
+  for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
+})
+
 beforeEach(() => {
   fsHarness.nextReadError = undefined
 })
@@ -38,6 +44,7 @@ beforeEach(() => {
 describe('display order', () => {
   it('puts declared order first, then everything else by id', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-order-'))
+    roots.push(root)
     for (const [id, order] of [['zulu', 1], ['alpha', 2]] as const) {
       await mkdir(join(root, id), { recursive: true })
       await writeFile(join(root, id, COMPOSITION_FILE), '[]\n')
@@ -57,6 +64,7 @@ describe('display order', () => {
 
   it('breaks a tie between equal declared orders by id', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-order-tie-'))
+    roots.push(root)
     for (const id of ['yankee', 'alpha']) {
       await mkdir(join(root, id), { recursive: true })
       await writeFile(join(root, id, COMPOSITION_FILE), '[]\n')
@@ -94,6 +102,7 @@ describe('preset discovery', () => {
 
   it('skips a directory whose name no preset id could ever claim', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-presets-oddname-'))
+    roots.push(root)
     await mkdir(join(root, '.hidden'))
     await mkdir(join(root, 'Has_Caps'))
     await mkdir(join(root, 'usable'))
@@ -129,6 +138,7 @@ describe('preset discovery', () => {
 
   it('ignores a plain file sitting beside the preset directories', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-presets-'))
+    roots.push(root)
     await writeFile(join(root, 'stray.yml'), '- id: x\n')
     await mkdir(join(root, 'real'))
     await writeFile(join(root, 'real', COMPOSITION_FILE), '[]\n')
@@ -140,6 +150,7 @@ describe('preset discovery', () => {
 
   it('reports a root it cannot read rather than treating it as empty', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-presets-'))
+    roots.push(root)
     const notADirectory = join(root, 'file-as-root')
     await writeFile(notADirectory, 'not a directory\n')
 
@@ -168,6 +179,7 @@ describe('composition health', () => {
    */
   async function scanned(composition: string): Promise<string | undefined> {
     const root = await mkdtemp(join(tmpdir(), 'dsh-presets-health-'))
+    roots.push(root)
     await mkdir(join(root, 'probe'))
     await writeFile(join(root, 'probe', COMPOSITION_FILE), composition)
     const [preset] = await scanRoot({ path: root, trust: 'user' }, HARNESS)
@@ -208,6 +220,7 @@ describe('composition health', () => {
 
   it('reports a composition that stats but cannot be read', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-presets-unreadable-'))
+    roots.push(root)
     await mkdir(join(root, 'sealed'))
     const path = join(root, 'sealed', COMPOSITION_FILE)
     await writeFile(path, '[]\n')
@@ -235,6 +248,7 @@ describe('rows naming a plugin that cannot be resolved', () => {
   /** One directory under a fresh root holding `composition`, scanned. */
   async function scanned(composition: string): Promise<string | undefined> {
     const root = await mkdtemp(join(tmpdir(), 'dsh-presets-resolve-'))
+    roots.push(root)
     await mkdir(join(root, 'probe'))
     await writeFile(join(root, 'probe', COMPOSITION_FILE), composition)
     const [preset] = await scanRoot({ path: root, trust: 'user' }, HARNESS)
@@ -272,6 +286,7 @@ describe('rows naming a plugin that cannot be resolved', () => {
 
   it('resolves a preset-relative row against the preset\'s own directory', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-presets-relative-'))
+    roots.push(root)
     await mkdir(join(root, 'probe'))
     await writeFile(join(root, 'probe', 'own-plugin.mjs'), 'export function apply() {}\n')
     await writeFile(join(root, 'probe', COMPOSITION_FILE), '- id: own\n  name: ./own-plugin.mjs\n- id: gone\n  name: ./deleted.mjs\n')
@@ -311,6 +326,7 @@ describe('rows naming a plugin that cannot be resolved', () => {
     // The fast path, and the one that has to answer alone: this package has a
     // directory and nothing to import, so a resolver would reject it.
     const home = await mkdtemp(join(tmpdir(), 'dsh-presets-installed-'))
+    roots.push(home)
     await mkdir(join(home, 'node_modules', '@scope', 'pkg'), { recursive: true })
     await writeFile(join(home, 'node_modules', '@scope', 'pkg', 'package.json'), '{"name":"@scope/pkg"}\n')
     await mkdir(join(home, 'presets', 'probe'), { recursive: true })
@@ -326,6 +342,7 @@ describe('rows naming a plugin that cannot be resolved', () => {
     // What a stale profile install leaves behind: the name is still in
     // `node_modules`, pointing at a checkout that is gone.
     const home = await mkdtemp(join(tmpdir(), 'dsh-presets-dangling-'))
+    roots.push(home)
     await mkdir(join(home, 'node_modules', '@scope'), { recursive: true })
     await symlink(join(home, 'deleted-checkout'), join(home, 'node_modules', '@scope', 'pkg'))
     await mkdir(join(home, 'presets', 'probe'), { recursive: true })

+ 9 - 2
packages/preset/agent-presets/tests/metadata.spec.ts

@@ -6,15 +6,22 @@
  * from the file a user can write.
  */
 
-import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
+import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it } from 'vitest'
 import { METADATA_FILE, readPresetMetadata, renderPresetMetadata } from '../src/metadata.ts'
 
+/** Every temp preset directory created by this file, removed after each test. */
+const tempDirs: string[] = []
+afterEach(async () => {
+  for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true })
+})
+
 /** A preset directory holding exactly the given metadata text. */
 async function presetDir(content?: string): Promise<string> {
   const dir = await mkdtemp(join(tmpdir(), 'dsh-preset-meta-'))
+  tempDirs.push(dir)
   await mkdir(dir, { recursive: true })
   if (content !== undefined) await writeFile(join(dir, METADATA_FILE), content)
   return dir

+ 13 - 1
packages/preset/agent-presets/tests/mount.spec.ts

@@ -13,7 +13,7 @@ import ToolRuntime from '@deepseek-ai/dsh-tools'
 import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
 import AgentLoop from '@deepseek-ai/dsh-agent-loop'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import AgentPresets, {
   COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, serviceForAgent,
 } from '@deepseek-ai/dsh-agent-presets'
@@ -86,6 +86,13 @@ function rootResolves(ctx: Context, name: string): boolean {
 }
 
 let ctx: Context
+
+/** Every temp preset root created by this file, removed after each test. */
+const roots: string[] = []
+afterEach(async () => {
+  for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
+})
+
 beforeEach(async () => {
   ctx = await harness()
 })
@@ -93,6 +100,7 @@ beforeEach(async () => {
 describe('composing an agent from a preset', () => {
   it('hands an absolute plugin path to Node as a file URL', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-preset-absolute-plugin-'))
+    roots.push(root)
     const presetDir = join(root, 'absolute')
     const plugin = join(FIXTURES, 'plugins', 'contribute.js')
     await mkdir(presetDir)
@@ -360,6 +368,7 @@ describe('composing from a broken preset', () => {
   /** A roster whose only user preset carries `composition`. */
   async function rosterWith(composition: string): Promise<Context> {
     const root = await mkdtemp(join(tmpdir(), 'dsh-preset-broken-'))
+    roots.push(root)
     await mkdir(join(root, 'damaged'))
     await writeFile(join(root, 'damaged', COMPOSITION_FILE), composition)
     return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }], includeShippedRoot: false, includeUserRoot: false })
@@ -428,6 +437,7 @@ describe('the preset file is an input, never a persistence target', () => {
     // committed fixture would be mutated by the very run that proves the bug
     // and every later run would compare against the damaged file and pass.
     const root = await mkdtemp(join(tmpdir(), 'dsh-preset-write-'))
+    roots.push(root)
     const dir = join(root, 'self-disposing')
     await mkdir(dir)
     const path = join(dir, COMPOSITION_FILE)
@@ -625,6 +635,7 @@ describe('replacing a composition', () => {
     // A preset root this test owns, so removing the composition mid-flight
     // cannot disturb the shipped fixtures.
     const root = await mkdtemp(join(tmpdir(), 'dsh-preset-restore-'))
+    roots.push(root)
     const seeded: [string, string][] = [['first', `- id: only\n  name: ${join(FIXTURES, 'plugins', 'contribute.js')}\n  config:\n    tool: only\n`], ['broken', `- id: nope\n  name: ${join(FIXTURES, 'plugins', 'throws.js')}\n  config:\n    message: refuses\n`]]
     for (const [id, body] of seeded) {
       await mkdir(join(root, id))
@@ -679,6 +690,7 @@ describe('editing a composition file', () => {
    */
   async function editable(id: string): Promise<{ scoped: Context; path: string }> {
     const root = await mkdtemp(join(tmpdir(), 'dsh-preset-edit-'))
+    roots.push(root)
     await mkdir(join(root, id))
     const path = join(root, id, COMPOSITION_FILE)
     await writeFile(path, rowFor('before'))

+ 13 - 2
packages/preset/agent-presets/tests/remote.spec.ts

@@ -4,7 +4,7 @@
  * which is the only one of the three that mutates an agent.
  */
 
-import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
 import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -34,7 +34,13 @@ const ROOTS = [
 // temp preset directory these tests seed would report the composition broken.
 const VALID = '- id: prompt\n  name: \'@deepseek-ai/dsh-system-prompt\'\n'
 
-afterEach(() => vi.restoreAllMocks())
+/** Every temp preset root created by this file, removed after each test. */
+const roots: string[] = []
+
+afterEach(async () => {
+  vi.restoreAllMocks()
+  for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
+})
 
 async function remoteFailure(operation: Promise<unknown>): Promise<RemoteFailure> {
   try {
@@ -97,6 +103,7 @@ const recordedPreset = (agent: Agent): unknown =>
 describe('the roster a client reads', () => {
   it('projects path-free rows, marking the default and carrying published metadata', async () => {
     const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-'))
+    roots.push(userRoot)
     await mkdir(join(userRoot, 'documented'), { recursive: true })
     await writeFile(join(userRoot, 'documented', COMPOSITION_FILE), VALID)
     await writeFile(join(userRoot, 'documented', METADATA_FILE), 'name: 我的模式\ndescription: 只做检索。\n')
@@ -122,6 +129,7 @@ describe('the roster a client reads', () => {
 
   it('keeps a broken preset on the roster with its reason', async () => {
     const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-'))
+    roots.push(userRoot)
     await mkdir(join(userRoot, 'damaged'), { recursive: true })
     const ctx = await harness({
       default: 'standard',
@@ -174,6 +182,7 @@ describe('reading one composition', () => {
 
   it('carries the display metadata a preset published', async () => {
     const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-'))
+    roots.push(userRoot)
     await mkdir(join(userRoot, 'documented'), { recursive: true })
     await writeFile(join(userRoot, 'documented', COMPOSITION_FILE), VALID)
     await writeFile(join(userRoot, 'documented', METADATA_FILE), 'name: 我的模式\ndescription: 只做检索。\n')
@@ -241,6 +250,7 @@ describe('authoring over Remote', () => {
 
   it('copies and deletes through the Remote adapters', async () => {
     const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-'))
+    roots.push(userRoot)
     const ctx = await harness({
       default: 'standard',
       roots: [{ path: join(FIXTURES, 'system'), trust: 'system' }, { path: userRoot, trust: 'user' }],
@@ -432,6 +442,7 @@ describe('switching one session\'s composition', () => {
 
   it('reports an unusable composition with its discovery reason', async () => {
     const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-'))
+    roots.push(userRoot)
     await mkdir(join(userRoot, 'damaged'), { recursive: true })
     const ctx = await harness({
       default: 'standard',

+ 10 - 2
packages/preset/agent-presets/tests/settings.spec.ts

@@ -4,7 +4,7 @@
  * so a person can change which preset new sessions get without a restart.
  */
 
-import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
 import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -19,13 +19,19 @@ import ToolRuntime from '@deepseek-ai/dsh-tools'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
 import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import FileSettingsProvider from '@deepseek-ai/dsh-settings-file'
-import { describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it } from 'vitest'
 import AgentPresets, { COMPOSITION_FILE, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets'
 
 const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
 const ROOTS = [{ path: join(FIXTURES, 'system'), trust: 'system' as const }]
 const NS = SETTINGS_NAMESPACE
 
+/** Every temp root created by this file, removed after each test. */
+const roots: string[] = []
+afterEach(async () => {
+  for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
+})
+
 /**
  * A composition with a real file-backed settings provider. `settingsFiber` is
  * the provider's own handle, so a test can take it away the way a reload does.
@@ -34,6 +40,7 @@ async function harness(
   extraRoots: readonly { path: string; trust: 'system' | 'user' }[] = [],
 ): Promise<{ ctx: Context; settingsFile: string; settingsFiber: { dispose: () => unknown } }> {
   const home = await mkdtemp(join(tmpdir(), 'dsh-preset-settings-'))
+  roots.push(home)
   const settingsFile = join(home, 'settings.yaml')
   await writeFile(settingsFile, '{}\n')
 
@@ -119,6 +126,7 @@ describe('the default preset as a user setting', () => {
 
   it('clears a user default it has just deleted', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-preset-authored-'))
+    roots.push(root)
     await mkdir(join(root, 'mine'))
     await writeFile(
       join(root, 'mine', COMPOSITION_FILE),

+ 6 - 3
packages/preset/agent-presets/tests/shipped-root.spec.ts

@@ -9,7 +9,7 @@
  * suite: the derived writable root is resolved in the constructor.
  */
 
-import { mkdtemp, readFile } from 'node:fs/promises'
+import { mkdtemp, readFile, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
 import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -24,16 +24,19 @@ import AgentPresets, { SHIPPED_PRESET_ROOT, type Config } from '@deepseek-ai/dsh
 const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
 const SYSTEM_ROOT = join(FIXTURES, 'system')
 
+let home: string
 let previousHome: string | undefined
 
 beforeEach(async () => {
   previousHome = process.env.DSH_HOME
-  process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-shipped-root-'))
+  home = await mkdtemp(join(tmpdir(), 'dsh-shipped-root-'))
+  process.env.DSH_HOME = home
 })
 
-afterEach(() => {
+afterEach(async () => {
   if (previousHome === undefined) delete process.env.DSH_HOME
   else process.env.DSH_HOME = previousHome
+  await rm(home, { recursive: true, force: true })
 })
 
 /** Boot a roster with the shipped root left to the plugin's default. */

+ 8 - 2
packages/preset/agent-presets/tests/user-root.spec.ts

@@ -10,7 +10,7 @@
  * temporary home, or it would reach the developer's real one.
  */
 
-import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
+import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
 import { existsSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
@@ -31,15 +31,20 @@ const VALID = '- id: tool-alpha\n  name: ../../plugins/contribute.js\n  config:\
 let home: string
 let previousHome: string | undefined
 
+/** Extra per-test roots, removed with the home directory. */
+const explicitRoots: string[] = []
+
 beforeEach(async () => {
   home = await mkdtemp(join(tmpdir(), 'dsh-preset-home-'))
   previousHome = process.env.DSH_HOME
   process.env.DSH_HOME = home
 })
 
-afterEach(() => {
+afterEach(async () => {
   if (previousHome === undefined) delete process.env.DSH_HOME
   else process.env.DSH_HOME = previousHome
+  await rm(home, { recursive: true, force: true })
+  for (const root of explicitRoots.splice(0)) await rm(root, { recursive: true, force: true })
 })
 
 /** Boot a roster over the fixture system root, with the derived root left to the plugin. */
@@ -120,6 +125,7 @@ describe('the harness-home preset root', () => {
 
   it('yields to a configured user root for authoring, which writableRoot takes first', async () => {
     const explicit = await mkdtemp(join(tmpdir(), 'dsh-preset-explicit-'))
+    explicitRoots.push(explicit)
     const ctx = await roster({
       roots: [
         { path: SYSTEM_ROOT, trust: 'system' as const },

+ 16 - 3
packages/sandbox/sandbox-local/tests/local.spec.ts

@@ -7,10 +7,10 @@
  * are all exercised through the real `confine()` path.
  */
 
-import { mkdtempSync, realpathSync, writeFileSync } from 'node:fs'
+import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import { LAUNCHER_FAILURE_EXIT } from '@deepseek-ai/node-addon-landlock-run'
 import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
@@ -24,6 +24,12 @@ import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from '../s
 const RO: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws' }
 const WW: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' }
 
+/** Every temp dir created by this file (fake launchers and runner entries), removed after each test. */
+const tempDirs: string[] = []
+afterEach(() => {
+  for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
+})
+
 async function setup(config: Config = {}, internals: LocalSandboxProvider['internals'] = {}) {
   const ctx = new Context()
   await ctx.plugin(LocalSandboxProvider, config)
@@ -39,12 +45,15 @@ async function setup(config: Config = {}, internals: LocalSandboxProvider['inter
  * `sandbox-windows-acl/lib/runner.js`.
  */
 function absentRunnerEntry(): string {
-  return join(mkdtempSync(join(tmpdir(), 'dsh-absent-acl-entry-')), 'runner.js')
+  const dir = mkdtempSync(join(tmpdir(), 'dsh-absent-acl-entry-'))
+  tempDirs.push(dir)
+  return join(dir, 'runner.js')
 }
 
 /** Write an executable fake `landlock-run` that answers `--probe` with `report`. */
 function fakeLauncher(report = 'landlock: fully enforced'): string {
   const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-'))
+  tempDirs.push(dir)
   const launcher = join(dir, 'landlock-run')
   writeFileSync(launcher, `#!/bin/sh\nif [ "$1" = "--probe" ]; then echo "${report}"; exit 0; fi\nexit ${LAUNCHER_FAILURE_EXIT}\n`, { mode: 0o755 })
   return launcher
@@ -53,6 +62,7 @@ function fakeLauncher(report = 'landlock: fully enforced'): string {
 /** Write an executable fake `sandbox-exec` that exits `status` for any invocation. */
 function fakeSeatbeltExec(status: number): string {
   const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-seatbelt-'))
+  tempDirs.push(dir)
   const exec = join(dir, 'sandbox-exec')
   writeFileSync(exec, `#!/bin/sh\nexit ${status}\n`, { mode: 0o755 })
   return exec
@@ -316,6 +326,7 @@ describe('the default landlock probe (launcher CLI contract)', () => {
 
   it('reads a failing launcher as unusable: the chain ends and fails closed', async () => {
     const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-'))
+    tempDirs.push(dir)
     const launcher = join(dir, 'landlock-run')
     writeFileSync(launcher, `#!/bin/sh\nexit ${LAUNCHER_FAILURE_EXIT}\n`, { mode: 0o755 })
     const { sandbox } = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher })
@@ -337,6 +348,7 @@ describe('probeTimeoutMs config', () => {
     // spawnSync blocks the worker and fork/exec latency inflates wall-clock)
     // cannot flip either verdict; the vitest timeout clears the patient budget.
     const dir = mkdtempSync(join(tmpdir(), 'dsh-slow-landlock-'))
+    tempDirs.push(dir)
     const launcher = join(dir, 'landlock-run')
     writeFileSync(launcher, '#!/bin/sh\nsleep 1\necho "landlock: fully enforced"\nexit 0\n', { mode: 0o755 })
 
@@ -440,6 +452,7 @@ describe('the windows-acl probe (runner invocation contract)', () => {
 
   it('prefers the built lib/runner.js entry when the resolved file exists', async () => {
     const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-acl-entry-'))
+    tempDirs.push(dir)
     const builtEntry = join(dir, 'runner.js')
     writeFileSync(builtEntry, '')
     const { sandbox } = await setup({}, {

+ 15 - 8
packages/sandbox/sandbox/tests/roots.spec.ts

@@ -4,16 +4,22 @@
  * deriving from `writableRoots` — cannot drift.
  */
 
-import { realpathSync } from 'node:fs'
+import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
-import { mkdtempSync } from 'node:fs'
 import { join } from 'node:path'
-import { describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it } from 'vitest'
 import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
 
+/** Every temp root created by this file, removed after each test. */
+const roots: string[] = []
+afterEach(() => {
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
 describe('canonicalPath', () => {
   it('resolves symlinks (an existing path realpaths)', () => {
     const dir = mkdtempSync(join(tmpdir(), 'dsh-roots-'))
+    roots.push(dir)
     expect(canonicalPath(dir)).toBe(realpathSync.native(dir))
   })
 
@@ -29,11 +35,12 @@ describe('writableRoots', () => {
 
   it('workspace-write grants the workspace root plus the platform temp areas, canonical and deduplicated', () => {
     const ws = mkdtempSync(join(tmpdir(), 'dsh-ws-'))
-    const roots = writableRoots({ mode: 'workspace-write', workspaceRoot: ws })
-    expect(roots).toContain(realpathSync.native(ws))
-    expect(roots).toContain(canonicalPath('/tmp'))
-    expect(roots).toContain(realpathSync.native(tmpdir()))
+    roots.push(ws)
+    const writable = writableRoots({ mode: 'workspace-write', workspaceRoot: ws })
+    expect(writable).toContain(realpathSync.native(ws))
+    expect(writable).toContain(canonicalPath('/tmp'))
+    expect(writable).toContain(realpathSync.native(tmpdir()))
     // Deduplicated after canonicalization (/tmp and os.tmpdir() may coincide).
-    expect(new Set(roots).size).toBe(roots.length)
+    expect(new Set(writable).size).toBe(writable.length)
   })
 })

+ 6 - 2
packages/shell/bash-local/tests/executor.spec.ts

@@ -1,7 +1,7 @@
-import { mkdtempSync } from 'node:fs'
+import { mkdtempSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { describe, expect, it } from 'vitest'
+import { afterAll, describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
 import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
@@ -10,6 +10,10 @@ import type { ShellProcess } from '@deepseek-ai/dsh-shell'
 
 const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
 
+afterAll(() => {
+  rmSync(spillDir, { recursive: true, force: true })
+})
+
 async function setup(config: ConstructorParameters<typeof LocalBashExecutor>[1] = {}) {
   const ctx = new Context()
   await ctx.plugin(LocalSubprocessRuntime)

+ 16 - 2
packages/shell/pwsh-local/tests/executor.spec.ts

@@ -9,11 +9,11 @@
  * writes CRLF on Windows, so exact text assertions normalize line endings.
  */
 
-import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { spawnSync } from 'node:child_process'
-import { describe, expect, it } from 'vitest'
+import { afterAll, afterEach, describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import { PwshLocalExecutor, ENCODING_PREAMBLE, candidatePwshPaths, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
 import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
@@ -24,6 +24,16 @@ import type { ShellProcess } from '@deepseek-ai/dsh-shell'
 
 const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-exec-spec-'))
 
+afterAll(() => {
+  rmSync(spillDir, { recursive: true, force: true })
+})
+
+/** Per-test temp dirs, removed after each test. */
+const tempDirs: string[] = []
+afterEach(() => {
+  for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
+})
+
 // The probe follows the executor's own resolution (Program Files installs on
 // Windows are found even when bare `pwsh` is not on PATH).
 const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
@@ -114,6 +124,7 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () =>
 
   it('returns the first EXISTING win32 candidate, else pwsh', () => {
     const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-'))
+    tempDirs.push(dir)
     const store = join(dir, 'store')
     mkdirSync(store, { recursive: true })
     writeFileSync(join(store, 'pwsh.exe'), '')
@@ -131,6 +142,7 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () =>
     // 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-'))
+    tempDirs.push(dir)
     const store = join(dir, 'store')
     mkdirSync(store, { recursive: true })
     const link = join(store, 'pwsh.exe')
@@ -141,6 +153,7 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () =>
 
   it('skips a directory candidate and falls through to the PATH-resolution default', () => {
     const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-dir-'))
+    tempDirs.push(dir)
     const store = join(dir, 'store')
     mkdirSync(join(store, 'pwsh.exe'), { recursive: true })
     expect(resolvePwshPath(undefined, {
@@ -201,6 +214,7 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => {
   it('uses config cwd, overridable per call', async () => {
     const first = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-a-'))
     const second = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-b-'))
+    tempDirs.push(first, second)
     const { bash } = await setup({ cwd: first })
     const fromConfig = await bash.run(bash.resolve({ command: '(Get-Location).Path' }))
     expect(samePath(fromConfig.stdout.text.trim(), first)).toBe(true)

+ 10 - 2
packages/skill/skill-filesystem/tests/skill-filesystem-watcher.spec.ts

@@ -3,7 +3,7 @@ import type { Stats } from 'node:fs'
 import { mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
 import { tmpdir } from 'node:os'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import SkillRegistry from '@deepseek-ai/dsh-skill'
 
@@ -91,8 +91,16 @@ vi.mock('chokidar', () => ({
 
 const SkillFileSystem = await import('../src/index.ts')
 
+/** Every temp dir created by this file, removed after each test. */
+const tempDirs: string[] = []
+afterEach(async () => {
+  for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true })
+})
+
 async function tempDir(name: string): Promise<string> {
-  return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
+  const dir = await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
+  tempDirs.push(dir)
+  return dir
 }
 
 async function writeSkill(root: string, name: string): Promise<void> {

+ 10 - 2
packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts

@@ -1,4 +1,4 @@
-import { describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it } from 'vitest'
 import { mkdir, readdir, readFile, rename, rm, stat, symlink, writeFile } from 'node:fs/promises'
 import { dirname, join } from 'node:path'
 import { tmpdir } from 'node:os'
@@ -7,8 +7,16 @@ import SkillRegistry from '@deepseek-ai/dsh-skill'
 import { FileSystem, FsError, FsVersion, type FsDirEntry, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsPathInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs'
 import * as SkillFileSystem from '../src/index.ts'
 
+/** Every temp dir created by this file, removed after each test. */
+const tempDirs: string[] = []
+afterEach(async () => {
+  for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true })
+})
+
 async function tempDir(name: string): Promise<string> {
-  return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
+  const dir = await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
+  tempDirs.push(dir)
+  return dir
 }
 
 async function writeSkill(root: string, name: string, description: string, body = 'Use the skill.'): Promise<void> {

+ 11 - 3
packages/skill/tool-skill/tests/tool-skill.spec.ts

@@ -1,5 +1,5 @@
-import { describe, expect, it } from 'vitest'
-import { mkdir, writeFile } from 'node:fs/promises'
+import { afterEach, describe, expect, it } from 'vitest'
+import { mkdir, rm, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
 import { tmpdir } from 'node:os'
 import { Context } from '@deepseek-ai/cordis'
@@ -15,8 +15,16 @@ import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
 
 const testToolSignal = new AbortController().signal
 
+/** Every temp dir created by this file, removed after each test. */
+const tempDirs: string[] = []
+afterEach(async () => {
+  for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true })
+})
+
 async function tempDir(name: string): Promise<string> {
-  return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
+  const dir = await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
+  tempDirs.push(dir)
+  return dir
 }
 
 async function writeSkill(root: string, name: string, description: string, body: string): Promise<void> {

+ 8 - 1
packages/spill/spill-local/src/store.ts

@@ -7,7 +7,7 @@
  */
 
 import { createHash, randomBytes } from 'node:crypto'
-import { mkdtempSync } from 'node:fs'
+import { mkdtempSync, rmSync } from 'node:fs'
 import { mkdir, open } from 'node:fs/promises'
 import { join } from 'node:path'
 import { tmpdir } from 'node:os'
@@ -38,6 +38,13 @@ export function privateRoot(): string {
   return defaultRoot
 }
 
+// The spill root is private to this process, so it is removed when the process
+// ends normally; a process killed with SIGKILL cannot run this, and its
+// residue is left to OS temp hygiene.
+process.once('exit', () => {
+  if (defaultRoot !== undefined) rmSync(defaultRoot, { recursive: true, force: true })
+})
+
 // Spill keeps its empty-name policy local so storage backends stay decoupled.
 /* jscpd:ignore-start */
 /**

+ 8 - 1
packages/subprocess/subprocess-local/src/spawn.ts

@@ -10,7 +10,7 @@
 import { type ChildProcess, spawn, spawnSync } from 'node:child_process'
 import type { Readable } from 'node:stream'
 import { randomBytes } from 'node:crypto'
-import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs'
+import { closeSync, mkdtempSync, openSync, rmSync, unlinkSync, writeSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { setTimeout as sleepMs } from 'node:timers/promises'
@@ -91,6 +91,13 @@ function privateSpillDir(): string {
   return defaultSpillDir
 }
 
+// The spill directory is private to this process, so it is removed when the
+// process ends normally; a process killed with SIGKILL cannot run this, and
+// its residue is left to OS temp hygiene.
+process.once('exit', () => {
+  if (defaultSpillDir !== undefined) rmSync(defaultSpillDir, { recursive: true, force: true })
+})
+
 /**
  * Collects one stream with a bounded in-memory tail. With a spill cap, on
  * first overflow a spill file is created and every chunk (including those

+ 6 - 2
packages/subprocess/subprocess-local/tests/spawn.spec.ts

@@ -1,7 +1,7 @@
-import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs'
+import { mkdtempSync, readFileSync, rmSync, statSync, unlinkSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
-import { describe, expect, it, vi } from 'vitest'
+import { afterAll, describe, expect, it, vi } from 'vitest'
 import {
   childEnv,
   killGroup,
@@ -82,6 +82,10 @@ vi.mock('node:fs', async (importOriginal) => {
 
 const spillDir = mkdtempSync(join(tmpdir(), 'dsh-subprocess-spec-'))
 
+afterAll(() => {
+  rmSync(spillDir, { recursive: true, force: true })
+})
+
 type SpecOverrides = Partial<Parameters<typeof spawnSubprocess>[0]> & {
   stdoutMaxBytes?: number
   stderrMaxBytes?: number

+ 10 - 3
scripts/coverage-partitions.spec.ts

@@ -1,4 +1,4 @@
-import { access, mkdir, mkdtemp, readFile, symlink, writeFile } from 'node:fs/promises'
+import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
 import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -22,7 +22,12 @@ import {
 
 const passed: CoverageCommandResult = { exitCode: 0, signalCode: null }
 
-afterEach(() => vi.restoreAllMocks())
+/** Every temporary root created by this file, removed after each test. */
+const roots: string[] = []
+afterEach(async () => {
+  vi.restoreAllMocks()
+  for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
+})
 
 async function writeBlob(command: CoverageCommand): Promise<void> {
   if (command.blobPath === undefined) return
@@ -31,7 +36,9 @@ async function writeBlob(command: CoverageCommand): Promise<void> {
 }
 
 async function temporaryRoot(): Promise<string> {
-  return await mkdtemp(join(tmpdir(), 'dsh-coverage-partitions-'))
+  const root = await mkdtemp(join(tmpdir(), 'dsh-coverage-partitions-'))
+  roots.push(root)
+  return root
 }
 
 /** Write a Vitest results cache under a temporary root. */