diff --git a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml new file mode 100644 index 0000000000..cb40bd0029 --- /dev/null +++ b/.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: 175fe82b0ad01c2afda3abb5f66ff455045a3cd6 +2026-08-28-test-temp-dir-self-cleanup.zh.md: c2fcd07d1619d1556ac99b336f27b89a5f46e426 diff --git a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md new file mode 100644 index 0000000000..175fe82b0a --- /dev/null +++ b/.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 cleanup is limited to the per-process spill directory of `dsh-subprocess-local/spawn` (`privateSpillDir`): it is removed at a JavaScript-observable process exit when it holds no completed spill file — completed spill files are retained as full-output recovery artifacts until an external cleanup, so only directories that never spilled (the dominant residue shape on the CI host: 92% of sampled `dsh-subprocess-*` dirs are empty) are removed. The removal is best-effort (ENOENT/ENOTEMPTY/EBUSY/EPERM must not change the exit code). `dsh-spill-local`'s default root is deliberately NOT exit-deleted: it is covered by the package's own 30-day startup sweep, and the [retention decision](../architecture/2026-07-17-local-spill-startup-cleanup.md) forbids deleting fresh spill artifacts that resumed or forked sessions may still reference. + +## Verification + +- Targeted local runs of every changed unit spec passed (the 36 changed `*.spec.ts` files, exercised in grouped runs), including the suites that exercise the changed product source; the two changed web `*.e2e.ts` files run under the web e2e lane. +- 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; a `dsh-subprocess-local` per-process spill directory holding no completed spill file is removed at a JavaScript-observable process exit. +- 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 spill dirs holding spill files, and `dsh-spill-local`'s default root, keep their files per the existing retention policy and are cleaned by that package's own sweep. diff --git a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md new file mode 100644 index 0000000000..c2fcd07d16 --- /dev/null +++ b/.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)确认在正常结束路径上本来干净,不改。 +- 产品侧清理限定在 `dsh-subprocess-local/spawn` 的每进程 spill 目录(`privateSpillDir`):在 JavaScript 可观察的进程退出时,仅当目录**未持有任何已完成的 spill 文件**才删除——已完成的 spill 文件作为完整输出恢复产物保留到外部清理,因此只有从未 spill 过的目录(CI 主机残留的主流形态:抽样 `dsh-subprocess-*` 目录 92% 为空)会被删除。删除是 best-effort(ENOENT/ENOTEMPTY/EBUSY/EPERM 不得改变退出码)。`dsh-spill-local` 的默认 root **刻意不做**退出删除:该 root 由包自带的 30 天启动 sweep 覆盖,且[保留策略 note](../architecture/2026-07-17-local-spill-startup-cleanup.zh.md)禁止删除 resume/fork 会话仍可能引用的新 spill 产物。 + +## Verification + +- 本地定向跑过全部改动单测 spec 通过(36 个改动的 `*.spec.ts` 文件,分组运行),含直接使用改动后产品源码的套件;2 个改动的 web `*.e2e.ts` 由 web e2e lane 承载。 +- 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 删除;`dsh-subprocess-local` 未持有任何已完成 spill 文件的每进程 spill 目录在 JavaScript 可观察的进程退出时删除。 +- 代价:被 SIGKILL 的进程(run 被取消、超时被杀)无法运行任何进程内 teardown,飞行中的残留仍在——机器侧 timer 继续兜底该路径。 +- 代价:子进程创建的目录只有在测试知道其路径时才被覆盖;产品自有、仍持有 spill 文件的目录与 `dsh-spill-local` 的默认 root 按既有保留策略保留文件,由该包自身的 sweep 清理。 diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index 72b8317fe4..4155fd338e 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/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 () => { diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 432c34a201..49e302c4c3 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/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 () => { diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index ebc50aa2fe..d5b569aa49 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/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)) diff --git a/packages/api/session-controller/tests/session-presets.host.spec.ts b/packages/api/session-controller/tests/session-presets.host.spec.ts index a30c72c86b..0b6e872653 100644 --- a/packages/api/session-controller/tests/session-presets.host.spec.ts +++ b/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) { diff --git a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts index dab997847b..3bcd3b0d04 100644 --- a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts +++ b/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 { @@ -39,6 +43,7 @@ function deferred(): Deferred { 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) diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index 644acd4892..2a172984d3 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/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', () => { diff --git a/packages/boot/app-boot/tests/config-dump.spec.ts b/packages/boot/app-boot/tests/config-dump.spec.ts index ef2c9fc68e..6703df20e0 100644 --- a/packages/boot/app-boot/tests/config-dump.spec.ts +++ b/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') diff --git a/packages/boot/app-boot/tests/config-reload.spec.ts b/packages/boot/app-boot/tests/config-reload.spec.ts index ca718a65b3..386b537ce4 100644 --- a/packages/boot/app-boot/tests/config-reload.spec.ts +++ b/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 = {}): Promise { 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'), [ diff --git a/packages/boot/app-boot/tests/hmr-config.spec.ts b/packages/boot/app-boot/tests/hmr-config.spec.ts index 5f52ab5e2a..6487ad0695 100644 --- a/packages/boot/app-boot/tests/hmr-config.spec.ts +++ b/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 { const ctx = new Context() @@ -32,6 +35,10 @@ async function eventually(test: () => boolean, message: string): Promise { } 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 }>() diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index 0681157f62..10d1d41b8a 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/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( diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index 3efdd22db5..a4f6debf97 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/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 { const deadline = Date.now() + 10_000 diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts index 6f41146c61..687a219a2c 100644 --- a/packages/boot/cmdline/tests/cmdline.spec.ts +++ b/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)[] = [] +/** 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 { const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-')) + tempDirs.push(dir) const observed: Observed = { exits: [], out: '' } writeFileSync(join(dir, 'reader.mjs'), ` export const name = 'reader' diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts index 3db8d68bf4..29c5a7666b 100644 --- a/packages/bundle/headless/tests/startup.spec.ts +++ b/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)[] = [] +/** 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 diff --git a/packages/bundle/web-app/tests/startup.spec.ts b/packages/bundle/web-app/tests/startup.spec.ts index ba3806232c..a10272f9d3 100644 --- a/packages/bundle/web-app/tests/startup.spec.ts +++ b/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)[] = [] +/** 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 } diff --git a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts index 435086a301..95ae891808 100644 --- a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts +++ b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts @@ -100,9 +100,14 @@ describe('workspace-write containment', () => { }) it('a write to the platform temp area lands (parity with the bash runner grant)', async () => { - const path = join(await mkdtemp(join(tmpdir(), 'dsh-fssbx-tmp-')), 'temp.txt') - await fs.writeText(await target(path), 'temp') - expect(await readFile(path, 'utf8')).toBe('temp') + const dir = await mkdtemp(join(tmpdir(), 'dsh-fssbx-tmp-')) + try { + const path = join(dir, 'temp.txt') + await fs.writeText(await target(path), 'temp') + expect(await readFile(path, 'utf8')).toBe('temp') + } finally { + await rm(dir, { recursive: true, force: true }) + } }) it('an absolute path outside the workspace is denied, no file created', async () => { diff --git a/packages/llm/llm-deepseek/tests/file-store.spec.ts b/packages/llm/llm-deepseek/tests/file-store.spec.ts index d6d154033b..9b97859ac8 100644 --- a/packages/llm/llm-deepseek/tests/file-store.spec.ts +++ b/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((_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() 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') diff --git a/packages/llm/llm-deepseek/tests/upload-index.spec.ts b/packages/llm/llm-deepseek/tests/upload-index.spec.ts index 2cad8a22be..989a921e69 100644 --- a/packages/llm/llm-deepseek/tests/upload-index.spec.ts +++ b/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) diff --git a/packages/preset/agent-presets/tests/authoring.spec.ts b/packages/preset/agent-presets/tests/authoring.spec.ts index 656f4bdb7f..0723fe93da 100644 --- a/packages/preset/agent-presets/tests/authoring.spec.ts +++ b/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) diff --git a/packages/preset/agent-presets/tests/composition-inventory.spec.ts b/packages/preset/agent-presets/tests/composition-inventory.spec.ts index fa6cdf596a..74565362e2 100644 --- a/packages/preset/agent-presets/tests/composition-inventory.spec.ts +++ b/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 { @@ -63,6 +67,7 @@ async function harness(roster: Config): Promise { 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', diff --git a/packages/preset/agent-presets/tests/discovery.spec.ts b/packages/preset/agent-presets/tests/discovery.spec.ts index 4ff1843557..3c22525bdf 100644 --- a/packages/preset/agent-presets/tests/discovery.spec.ts +++ b/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 { 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 { 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 }) diff --git a/packages/preset/agent-presets/tests/metadata.spec.ts b/packages/preset/agent-presets/tests/metadata.spec.ts index 8bdabe1c59..99ed7b97f8 100644 --- a/packages/preset/agent-presets/tests/metadata.spec.ts +++ b/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 { 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 diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 9d5d052724..bd78447fff 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/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 { 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')) diff --git a/packages/preset/agent-presets/tests/remote.spec.ts b/packages/preset/agent-presets/tests/remote.spec.ts index 15b683a986..d04eed3514 100644 --- a/packages/preset/agent-presets/tests/remote.spec.ts +++ b/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): Promise { 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', diff --git a/packages/preset/agent-presets/tests/settings.spec.ts b/packages/preset/agent-presets/tests/settings.spec.ts index 9445eb604e..887c288aa5 100644 --- a/packages/preset/agent-presets/tests/settings.spec.ts +++ b/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), diff --git a/packages/preset/agent-presets/tests/shipped-root.spec.ts b/packages/preset/agent-presets/tests/shipped-root.spec.ts index 323c56ee12..6b6dcd1789 100644 --- a/packages/preset/agent-presets/tests/shipped-root.spec.ts +++ b/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. */ diff --git a/packages/preset/agent-presets/tests/user-root.spec.ts b/packages/preset/agent-presets/tests/user-root.spec.ts index 6da05b2ca7..f8eaf15a6d 100644 --- a/packages/preset/agent-presets/tests/user-root.spec.ts +++ b/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 }, diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 285afb865b..3cfcdde6e3 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/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({}, { diff --git a/packages/sandbox/sandbox/tests/roots.spec.ts b/packages/sandbox/sandbox/tests/roots.spec.ts index 49fdc03816..1541363b95 100644 --- a/packages/sandbox/sandbox/tests/roots.spec.ts +++ b/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) }) }) diff --git a/packages/shell/bash-local/tests/executor.spec.ts b/packages/shell/bash-local/tests/executor.spec.ts index c081ad7078..0f5b7992f3 100644 --- a/packages/shell/bash-local/tests/executor.spec.ts +++ b/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[1] = {}) { const ctx = new Context() await ctx.plugin(LocalSubprocessRuntime) diff --git a/packages/shell/bash-sandbox/tests/sandbox.spec.ts b/packages/shell/bash-sandbox/tests/sandbox.spec.ts index 4029cc4f40..21d2dfceaa 100644 --- a/packages/shell/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/shell/bash-sandbox/tests/sandbox.spec.ts @@ -8,7 +8,7 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' -import { describe, expect, it, vi } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { ShellRunResult, CollectedOutput } from '@deepseek-ai/dsh-shell' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -23,6 +23,10 @@ import type { Config } from '@deepseek-ai/dsh-bash-sandbox' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-')) +afterAll(() => { + rmSync(spillDir, { recursive: true, force: true }) +}) + /** One recorded provider call: the argv handed over and the policy it rode with. */ interface ConfineCall { argv: string[] @@ -518,12 +522,17 @@ describe('result facts', () => { it('reports a real permission failure as a sandbox denial with the mode it ran under', async () => { const { bash } = await setup() - const lockedDir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-denied-')), 'locked') - mkdirSync(lockedDir) - chmodSync(lockedDir, 0o555) - const result = await bash.run(bash.resolve({ command: `echo x > ${lockedDir}/f` })) - expect(result.exitCode).not.toBe(0) - expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) + const deniedRoot = mkdtempSync(join(tmpdir(), 'dsh-sandbox-denied-')) + try { + const lockedDir = join(deniedRoot, 'locked') + mkdirSync(lockedDir) + chmodSync(lockedDir, 0o555) + const result = await bash.run(bash.resolve({ command: `echo x > ${lockedDir}/f` })) + expect(result.exitCode).not.toBe(0) + expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) + } finally { + rmSync(deniedRoot, { recursive: true, force: true }) + } }) it('carries the provider\'s partial-enforcement fact through unchanged', async () => { diff --git a/packages/shell/pwsh-local/tests/executor.spec.ts b/packages/shell/pwsh-local/tests/executor.spec.ts index 53047a62c8..319c588ac5 100644 --- a/packages/shell/pwsh-local/tests/executor.spec.ts +++ b/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) diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index 747d98b48d..6da7972087 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.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, vi } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { ShellExecutor } from '@deepseek-ai/dsh-shell' @@ -29,6 +29,10 @@ const testToolSignal = new AbortController().signal const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-')) +afterAll(() => { + rmSync(spillDir, { recursive: true, force: true }) +}) + /** Foreground-only harness: no job runtime (backgrounding fails loud here). */ async function setup() { const ctx = new Context() diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts index 2bf101b9e2..dbd1f13bdd 100644 --- a/packages/shell/tool-pwsh/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh/tests/tools.spec.ts @@ -10,9 +10,9 @@ * is pinned separately in integration.spec.ts. */ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { mkdtempSync, realpathSync } from 'node:fs' +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve as resolvePath } from 'node:path' import { ToolCallId } from '@deepseek-ai/dsh-llm' @@ -38,6 +38,12 @@ import { renderPwshProcessRead, renderPwshResult } from '../src/render.ts' const testToolSignal = new AbortController().signal +/** Per-test temp dirs (session cwd/home fixtures), removed after each test. */ +const tempDirs: string[] = [] +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + /** * A scriptable fake executor: `resolve()` mirrors the real defaulting, `run()` * returns the armed foreground script, `start()` returns the armed background @@ -388,6 +394,7 @@ describe('argument validation', () => { describe('execution through the bash seam', () => { it('forwards command, session cwd, timeout, and managed DSH_* environment', async () => { const dshHome = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-home-')) + tempDirs.push(dshHome) const { ctx, bash } = await setup({}, dshHome) bash.handler = () => runResult('hi\n') const agent = registerFakeAgent(ctx, 'session-1') @@ -538,6 +545,7 @@ describe('per-call sandbox policy resolution', () => { it('stamps the CALLING SESSION\'s resolved policy onto the request (session cwd, not the server launch dir)', async () => { const { ctx, bash } = await setupSandboxed() const sessionCwd = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-policy-')) + tempDirs.push(sessionCwd) const agent = registerFakeAgent(ctx, 'policy-session') Object.assign(agent.session.header, { cwd: sessionCwd }) const result = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }, agent) diff --git a/packages/skill/skill-filesystem/tests/skill-filesystem-watcher.spec.ts b/packages/skill/skill-filesystem/tests/skill-filesystem-watcher.spec.ts index 95a0883a33..db57f3f6f5 100644 --- a/packages/skill/skill-filesystem/tests/skill-filesystem-watcher.spec.ts +++ b/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 { - 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 { diff --git a/packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts b/packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts index 238d1421c7..054535d7e7 100644 --- a/packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts +++ b/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 { - 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 { diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index d9c9fe89be..ffcb54268b 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/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' @@ -17,8 +17,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 { - 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 { diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 5cbeb336fe..7a129abee9 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-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/subprocess/subprocess-local/README.md -README.md: cacd900729237d60f1bfeab9723ed83be3d9f3aa -README.zh.md: 559ec64cc3cb0d416934c821090daef3df999c58 +README.md: d8e44d160a01fa2f394aaf781cf90728b0506c77 +README.zh.md: 4a3fed4fa8525be1428a61c8bdc1c3fb8497f263 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index cacd900729..d8e44d160a 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -50,7 +50,7 @@ Collect mode keeps the last `maxBytes` of a stream in memory — errors and fina ### Shutdown behavior -Normal disposal terminates every running tree and terminal and awaits their exit. During a JavaScript-observable host exit — direct `process.exit()`, default uncaught exceptions, default unhandled rejections — a synchronous finalization force-terminates everything still owned (SIGKILL to the group, `taskkill /T /F` on Windows) without creating promises or timers. Unhandled `SIGTERM`/`SIGINT`/`SIGHUP`, `SIGKILL`, fatal OOM, native crashes, and power loss need an external supervisor. +Normal disposal terminates every running tree and terminal and awaits their exit. During a JavaScript-observable host exit — direct `process.exit()`, default uncaught exceptions, default unhandled rejections — a synchronous finalization force-terminates everything still owned (SIGKILL to the group, `taskkill /T /F` on Windows) without creating promises or timers. The same exit removes the private per-process spill directory when it holds no completed spill file (completed spill files are retained as full-output recovery artifacts until an external cleanup). Unhandled `SIGTERM`/`SIGINT`/`SIGHUP`, `SIGKILL`, fatal OOM, native crashes, and power loss need an external supervisor. ### What can go wrong @@ -127,7 +127,7 @@ These limits define when the provider is a poor fit or needs special operational - **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the PTY root; on Linux, a `setsid` child leaves both the tree and the owned terminal session; the provider adds no continuous process-table monitor. - **In-process cleanup requires a JavaScript-observable exit** — direct `process.exit()`, default uncaught exceptions, and default unhandled rejections emit Node's synchronous `exit` event; an unhandled `SIGTERM`, `SIGINT`, or `SIGHUP`, `SIGKILL`, fatal OOM, `process.abort()`, native crashes, and power loss require an external supervisor, container init, or equivalent OS owner. - **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently named secrets (for example `*PASSPHRASE*`) pass through, and a whitelist for over-scrubbed variables is noted future work. -- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill directory) accumulate under the OS tmpdir until something external cleans them. +- **Completed spill files are not deleted** — bounded full-output recovery files accumulate under the OS tmpdir until something external cleans them; the private per-process spill directory is removed at a JavaScript-observable exit only when it holds no completed spill file. ### Dev Note diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 559ec64cc3..4a3fed4fa8 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -50,7 +50,7 @@ kind: "package-reference" ### 关闭行为 -正常 dispose 会终止每棵仍在运行的进程树与终端并等待其退出。在 JavaScript 可观察的宿主退出期间——直接 `process.exit()`、默认未捕获异常、默认未处理 rejection——同步最终清理会强制终止所有仍归本包所有的对象(对进程组发送 SIGKILL,Windows 上运行 `taskkill /T /F`),且不创建任何 Promise 或定时器。未处理的 `SIGTERM`/`SIGINT`/`SIGHUP`、`SIGKILL`、fatal OOM、native crash 与断电则需要外部 supervisor。 +正常 dispose 会终止每棵仍在运行的进程树与终端并等待其退出。在 JavaScript 可观察的宿主退出期间——直接 `process.exit()`、默认未捕获异常、默认未处理 rejection——同步最终清理会强制终止所有仍归本包所有的对象(对进程组发送 SIGKILL,Windows 上运行 `taskkill /T /F`),且不创建任何 Promise 或定时器。同一退出阶段会删除每进程私有 spill 目录(仅当其未持有任何已完成的 spill 文件时;已完成的 spill 文件作为完整输出恢复产物保留,直到外部机制清理)。未处理的 `SIGTERM`/`SIGINT`/`SIGHUP`、`SIGKILL`、fatal OOM、native crash 与断电则需要外部 supervisor。 ### 可能出错的地方 @@ -127,7 +127,7 @@ spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目 - **守护化的终端后代仍可能逃出可观察边界**——在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 PTY 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话;本提供方不新增持续进程表监视器。 - **进程内清理要求退出阶段仍能执行 JavaScript**——直接 `process.exit()`、默认未捕获异常和默认未处理 rejection 会发出 Node 同步 `exit` 事件;未处理的 `SIGTERM`、`SIGINT` 或 `SIGHUP`、`SIGKILL`、fatal OOM、`process.abort()`、native crash 与断电,都需要外部 supervisor、容器 init 或等价的 OS 所有者负责。 - **凭据清除依赖名称启发式规则**——只匹配 `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 -- **不会删除已完成的 spill 文件**——有界的完整输出恢复文件(以及每进程私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理。 +- **不会删除已完成的 spill 文件**——有界的完整输出恢复文件会在 OS tmpdir 下累积,直到外部机制进行清理;每进程私有 spill 目录仅在未持有任何已完成 spill 文件时于 JavaScript 可观察的退出阶段删除。 ### 开发备注 diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 2d62fab504..03f1451c64 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -10,7 +10,7 @@ import { type ChildProcess, type SpawnOptions, 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, rmdirSync, unlinkSync, writeSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { setTimeout as sleepMs } from 'node:timers/promises' @@ -92,13 +92,27 @@ let defaultSpillDir: string | undefined /** * The default spill location: a private (0700) per-process directory under * the OS tmpdir, created lazily. Predictable world-readable paths would let - * other local users read command output or pre-create symlinks. + * other local users read command output or pre-create symlinks. At a + * JavaScript-observable process exit the directory is removed only when it + * holds no completed spill file (spill files are retained as full-output + * recovery artifacts until an external cleanup). */ function privateSpillDir(): string { defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-subprocess-')) return defaultSpillDir } +// The per-process spill directory is removed at process exit when it holds no +// completed spill file: a directory that never spilled is empty and is safe to +// remove, while a directory holding completed spill files keeps them (their +// content is retained until an external cleanup). A SIGKILLed process cannot +// run this at all; its residue is left to OS temp hygiene. +/* v8 ignore next 4 -- exit listeners run after the coverage dump; removal is verified by the CI /tmp residue measurement. */ +process.once('exit', () => { + if (defaultSpillDir === undefined) return + try { rmdirSync(defaultSpillDir) } catch { /* best-effort: ENOENT/ENOTEMPTY/EBUSY/EPERM must not change the exit code. */ } +}) + /** * 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 diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 6392cf248c..262a6ea56a 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -1,8 +1,8 @@ import { spawn as nodeSpawn, spawnSync as nodeSpawnSync } from 'node:child_process' -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, @@ -88,6 +88,14 @@ vi.mock('node:fs', async (importOriginal) => { const spillDir = mkdtempSync(join(tmpdir(), 'dsh-subprocess-spec-')) +/** The per-process default spill dir captured by the default-spill test. */ +let defaultSpillDir: string | undefined + +afterAll(() => { + rmSync(spillDir, { recursive: true, force: true }) + if (defaultSpillDir !== undefined) rmSync(defaultSpillDir, { recursive: true, force: true }) +}) + type SpecOverrides = Partial[0]> & { stdoutMaxBytes?: number stderrMaxBytes?: number @@ -1127,6 +1135,7 @@ describe('environment and spill-file hardening', () => { spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), )) const dir = dirname(result.stdout.spillPath!) + defaultSpillDir = dir expect(dir).toMatch(/dsh-subprocess-/) const mode = statSync(dir).mode & 0o777 expect(mode).toBe(0o700) diff --git a/scripts/coverage-partitions.spec.ts b/scripts/coverage-partitions.spec.ts index 6a1774cff4..e30ccf915a 100644 --- a/scripts/coverage-partitions.spec.ts +++ b/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 { if (command.blobPath === undefined) return @@ -31,7 +36,9 @@ async function writeBlob(command: CoverageCommand): Promise { } async function temporaryRoot(): Promise { - 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. */