Jelajahi Sumber

Merge pull request #3925 from deepseek-harness/turtle/dsh-cache-path

feat(attachment-local): use shared cache for request images
Turtle 3 minggu lalu
induk
melakukan
3ece27847c

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md
-2026-07-24-single-harness-home-resolver.md: 02578674b4547bae54b2edce99c6c4ef53748588
-2026-07-24-single-harness-home-resolver.zh.md: 5764be3f85901196cd2a2db77d1fc3838668163f
+2026-07-24-single-harness-home-resolver.md: 1191c52bfa222b807e9aa301b250d2efcc8b0953
+2026-07-24-single-harness-home-resolver.zh.md: cefa124b7cb46bab92157af34576f59d3053da9f

+ 2 - 0
.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md

@@ -23,6 +23,8 @@ explicit configured path  >  $DSH_HOME  >  ~/.dsh
 
 An empty or whitespace-only `$DSH_HOME` is treated as unset; otherwise `resolve('')` would silently place the home at the current working directory. The harness keeps all user data under one root; there is no XDG config/data/cache split. `dshHomePath(...segments)` joins deployment-owned children onto that root, and `dsh-app-boot` exposes it to Loader `!!js` config expressions before mounting entries, so shipped compositions derive `sessions` and `storages` without copying the resolver. `dshHomeDisplay()` names a resolved root symbolically for user-facing paths — `~/.dsh` for the default home, `$DSH_HOME` for any configured home — so the user-global `AGENTS.md` label never leaks an absolute machine path. It replaces agent-instructions's bespoke default-vs-`$DSH_HOME` check.
 
+`dshCachePath(...segments)` derives paths below the resolved home's `cache` directory. An initial `{ dshHome }` option preserves a provider's explicit home override. It resolves paths without creating directories; callers own directory creation. `attachment-local` uses this helper for regenerable request-image variants while retaining durable attachment objects in their versioned storage tree, so clearing the cache cannot remove Session attachments. Existing request-image cache entries are left in place and are not read or copied; a cache miss regenerates the variant from its durable attachment.
+
 `@deepseek-ai/dsh-home` is deleted. Home-owning providers and boot packages import `resolveDshHome` from `dsh-home-paths`; composition bundles contain only the resolved configuration rows.
 
 `dsh-telemetry` and its separate home policy are absent under the [SDK project toolchain removal](../../archived/simplification/2026-08-11-remove-sdk-project-toolchain.md), leaving this resolver as the sole home policy.

+ 2 - 0
.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md

@@ -23,6 +23,8 @@ explicit configured path  >  $DSH_HOME  >  ~/.dsh
 
 空或仅含空白的 `$DSH_HOME` 被当作未设置处理;否则,`resolve('')` 会悄悄把 home 落在当前工作目录。harness 把所有用户数据都放在同一个根目录下;不存在 XDG 的 config/data/cache 拆分。`dshHomePath(...segments)` 将部署负责的子路径拼接到该根目录下,`dsh-app-boot` 在挂载条目前向 Loader `!!js` 配置表达式暴露它,因此出厂组合无需复制解析器即可派生 `sessions` 和 `storages`。`dshHomeDisplay()` 为面向用户的路径以符号形式命名已解析的根目录——默认 home 显示为 `~/.dsh`,任何已配置的 home 显示为 `$DSH_HOME`——这样用户全局的 `AGENTS.md` 标签就绝不会泄露机器上的绝对路径。它取代了 agent-instructions 中自定义的「默认值 vs `$DSH_HOME`」判断。
 
+`dshCachePath(...segments)` 在解析出的主目录下的 `cache` 目录中派生路径。首个 `{ dshHome }` 选项保留提供方显式配置的主目录覆盖值。它只解析路径,不创建目录;目录创建由调用方负责。`attachment-local` 将此函数用于可重新生成的请求图片版本,持久附件对象仍保留在其带版本的存储树中,因此清空缓存不会删除 Session 附件。已有请求图片缓存条目保留在原处,不再读取或复制;缓存未命中时从持久附件重新生成请求版本。
+
 `@deepseek-ai/dsh-home` 被删除。拥有 home 配置的提供方与 boot 包从 `dsh-home-paths` 导入 `resolveDshHome`;组合包只包含解析后的配置行。
 
 `dsh-telemetry` 及其独立 home 策略已随 [SDK 项目工具链移除](../../archived/simplification/2026-08-11-remove-sdk-project-toolchain.md)一并消失,因此该解析器是唯一的 home 策略。

+ 2 - 2
packages/attachment/attachment-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/attachment/attachment-local/README.md
-README.md: a14490f855268cdaa65bc0d3d8968ece56d25bf3
-README.zh.md: 44e8205dfdc7debd625c1ad4819f56bcfbe94728
+README.md: c7d5e58cca77bfb182964a22139d6925bdc64501
+README.zh.md: fdfcf02f20cece5f015e438283b6cd4e426f66d9

+ 1 - 1
packages/attachment/attachment-local/README.md

@@ -85,7 +85,7 @@ Objects land at `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>`; eq
 
 Admission accepts up to 20 images and 200 MiB of source bytes per message; one source may use up to 20 MiB, 64 million pixels, and 8192 pixels per side. It applies orientation, removes metadata and color profiles, and normalizes under a 2048×2048 total-pixel budget, an 8192-pixel long edge, and a 4 MiB encoded-byte target. Extreme aspect ratios therefore retain their short-edge resolution. Clean single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP input already within those limits passes through byte-identically; GIF, animation, metadata, orientation, 16-bit PNG, and incompatible color spaces force conversion.
 
-Request versions live below `<DSH_HOME>/attachments/v1/request-images/`. `readImageRequest` scales without enlargement to a route pixel budget, then applies a separate encoded-byte target through the same alpha routing and quality ladder. Its cache identity includes the attachment id, transform version, budgets, and fixed encoder settings; cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimensions, and alpha facts, and a mismatch regenerates the entry. Concurrent callers share one transform and cache write, while cancellation stops shared work only when no waiter remains. `imageHostPath` derives the normalized object's host path, and the mounted filesystem may map that path into its execution world without writing it to durable history.
+Request versions live below `<DSH_HOME>/cache/attachments/request-images/`, resolved by `dshCachePath`; an explicit `dshHome` setting applies to both cache and durable storage. Clearing this cache between requests preserves durable attachments, and later reads regenerate the variants. `readImageRequest` scales without enlargement to a route pixel budget, then applies a separate encoded-byte target through the same alpha routing and quality ladder. Its cache identity includes the attachment id, transform version, budgets, and fixed encoder settings; cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimensions, and alpha facts, and a mismatch regenerates the entry. Concurrent callers share one transform and cache write, while cancellation stops shared work only when no waiter remains. `imageHostPath` derives the normalized object's host path, and the mounted filesystem may map that path into its execution world without writing it to durable history.
 
 Generic-file bytes have one canonical object at `<DSH_HOME>/attachments/v1/file-objects/<digest-prefix>/<digest>`. Each reference path at `<DSH_HOME>/attachments/v1/files/<digest-prefix>/<digest>/<name>` is a read-only hard link, so different names for equal bytes do not duplicate disk content. `readFileStream` reads the reference path in bounded chunks and verifies the complete digest and recorded byte count before a consumer can finish successfully. A missing, changed, or truncated object fails its consumer instead of producing a complete export with different bytes.
 

+ 1 - 1
packages/attachment/attachment-local/README.zh.md

@@ -85,7 +85,7 @@ kind: "package-reference"
 
 准入允许每条消息最多 20 张图片与 200 MiB 源字节;单个源图最多 20 MiB、6400 万像素与单边 8192 像素。系统应用方向、移除元数据与色彩配置,并把规范化结果限制在 2048×2048 总像素预算、8192 像素长边和 4 MiB 编码字节目标内,因此,即使宽高比极端,图片也会保留短边分辨率。已经满足限制的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 会逐字节直通;GIF、动画、元数据、方向、16-bit PNG 与不兼容色彩空间会触发转换。
 
-请求版本位于 `<DSH_HOME>/attachments/v1/request-images/`。`readImageRequest` 在不放大的前提下缩放到路由像素预算,再通过相同的 alpha 路由与质量阶梯应用独立编码字节目标。缓存身份包含附件 id、变换版本、预算与固定编码参数;缓存字节会先通过文件头探测格式、8-bit sRGB/sRGBA、尺寸与 alpha 信息,不匹配时重新生成。并发调用方共享一次变换与缓存写入,且只在没有等待方时由取消停止共享工作。`imageHostPath` 派生规范化对象的宿主路径,挂载的文件系统可以把该路径映射进执行世界,而不会写入持久历史。
+请求版本位于由 `dshCachePath` 解析的 `<DSH_HOME>/cache/attachments/request-images/`;显式 `dshHome` 设置同时适用于缓存与持久存储。在两次请求之间清空此缓存会保留持久附件,后续读取会重新生成请求版本。`readImageRequest` 在不放大的前提下缩放到路由像素预算,再通过相同的 alpha 路由与质量阶梯应用独立编码字节目标。缓存身份包含附件 id、变换版本、预算与固定编码参数;缓存字节会先通过文件头探测格式、8-bit sRGB/sRGBA、尺寸与 alpha 信息,不匹配时重新生成。并发调用方共享一次变换与缓存写入,且只在没有等待方时由取消停止共享工作。`imageHostPath` 派生规范化对象的宿主路径,挂载的文件系统可以把该路径映射进执行世界,而不会写入持久历史。
 
 通用文件字节的唯一规范对象位于 `<DSH_HOME>/attachments/v1/file-objects/<digest-prefix>/<digest>`。每条引用路径 `<DSH_HOME>/attachments/v1/files/<digest-prefix>/<digest>/<name>` 都是只读硬链接,所以名称不同但字节相同的文件不会重复占用磁盘。`readFileStream` 以有界分块读取引用路径,并在消费方成功结束前校验完整摘要与记录的字节数。对象缺失、被改写或截断时,消费方会失败,不会得到字节已经变化的完整导出。
 

+ 7 - 4
packages/attachment/attachment-local/src/index.ts

@@ -1,6 +1,6 @@
 /** Local durable attachment backend rooted below `DSH_HOME`. @module @deepseek-ai/dsh-attachment-local */
 
-import { join, resolve } from 'node:path'
+import { join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import { AttachmentStore } from '@deepseek-ai/dsh-attachment'
@@ -15,7 +15,7 @@ import type {
   SaveImageAttachment,
   StoredImageAttachment,
 } from '@deepseek-ai/dsh-attachment'
-import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
+import { dshCachePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
 import type { NormalizationPolicy } from './normalization.ts'
 import { CompressionLimiter, compressionFailure } from './compression-limiter.ts'
 import { commitPreparedImageFile, normalizedImagePath, prepareImageFile, readImageFile, validateImageFile } from './store.ts'
@@ -166,12 +166,15 @@ export class LocalAttachmentStore extends AttachmentStore {
   readonly normalizationPolicy: Readonly<NormalizationPolicy>
   /** Resolved instance-level compression limit. */
   readonly imageCompressionConcurrency: number
+  private readonly cacheRoot: string
   private readonly compression: CompressionLimiter
   private readonly requestInflight = new Map<string, SharedRequest<RequestImageAttachment>>()
 
   constructor(ctx: Context, config: Config) {
     super(ctx)
-    this.root = resolve(join(resolveDshHome(config.dshHome), 'attachments', 'v1'))
+    const dshHome = resolveDshHome(config.dshHome)
+    this.root = join(dshHome, 'attachments', 'v1')
+    this.cacheRoot = dshCachePath({ dshHome }, 'attachments')
     this.imageLimits = Object.freeze({
       maxImageBytes: config.maxImageBytes ?? DEFAULT_MAX_IMAGE_BYTES,
       maxImagesPerMessage: config.maxImagesPerMessage ?? DEFAULT_MAX_IMAGES_PER_MESSAGE,
@@ -267,7 +270,7 @@ export class LocalAttachmentStore extends AttachmentStore {
     if (operation === undefined) {
       const shared = new SharedRequest<RequestImageAttachment>(sharedSignal => this.compression.run(async () => {
         const request = await readRequestImageFile(
-          this.root,
+          this.cacheRoot,
           stored ?? await this.readImage(ref, sharedSignal),
           policy,
           sharedSignal,

+ 2 - 2
packages/attachment/attachment-local/src/request-image.ts

@@ -166,8 +166,8 @@ async function writeCached(path: string, data: Uint8Array): Promise<void> {
 }
 
 /**
- * Generate or reuse one request image below the local attachment root.
- * @param root - absolute versioned attachment storage root.
+ * Generate or reuse one request image below the local attachment cache root.
+ * @param root - absolute attachment cache root; variants use its `request-images` child.
  * @param attachment - verified normalized attachment bytes and reference.
  * @param policy - exact route request-image policy.
  * @param signal - optional cancellation for cache I/O and image transformation.

+ 44 - 5
packages/attachment/attachment-local/tests/request-image.spec.ts

@@ -1,4 +1,4 @@
-import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
@@ -9,10 +9,14 @@ import LocalAttachmentStore from '../src/index.ts'
 
 const homes: string[] = []
 
-async function store(): Promise<LocalAttachmentStore> {
+async function home(): Promise<string> {
   const dshHome = await mkdtemp(join(tmpdir(), 'dsh-request-image-'))
   homes.push(dshHome)
-  return new LocalAttachmentStore(new Context(), { dshHome })
+  return dshHome
+}
+
+async function store(): Promise<LocalAttachmentStore> {
+  return new LocalAttachmentStore(new Context(), { dshHome: await home() })
 }
 
 async function image(width: number, height: number): Promise<Uint8Array> {
@@ -39,10 +43,44 @@ async function complexOpaqueAlphaImage(width: number, height: number): Promise<U
 }
 
 afterEach(async () => {
+  vi.unstubAllEnvs()
   await Promise.all(homes.splice(0).map(home => rm(home, { recursive: true, force: true })))
 })
 
 describe('local request-image cache', () => {
+  it('rebuilds a cleared cache without moving or losing durable attachments', async () => {
+    const fallbackHome = await home()
+    vi.stubEnv('DSH_HOME', fallbackHome)
+    try {
+      const dshHome = await home()
+      const attachments = new LocalAttachmentStore(new Context(), { dshHome })
+      const attachment = await attachments.saveImage({ data: await image(64, 32), mediaType: 'image/png' })
+      const stored = await attachments.readImage(attachment)
+      const fileData = Uint8Array.of(0, 1, 2, 255)
+      const file = await attachments.saveFile({ data: fileData, name: 'notes.bin' })
+      const policy = { maxPixels: 16 * 16, maxBytes: 4_096 }
+      const initial = await attachments.readImageRequest(attachment, policy)
+      const hash = String(initial.variantId).slice('sha256:'.length)
+      const cacheRoot = join(dshHome, 'cache')
+      const path = join(cacheRoot, 'attachments', 'request-images', hash.slice(0, 2), hash)
+
+      expect(attachments.root).toBe(join(dshHome, 'attachments', 'v1'))
+      await expect(readFile(path)).resolves.toEqual(Buffer.from(initial.data))
+      await expect(readFile(join(attachments.root, 'request-images', hash.slice(0, 2), hash)))
+        .rejects.toMatchObject({ code: 'ENOENT' })
+      await rm(cacheRoot, { recursive: true })
+
+      const reopened = new LocalAttachmentStore(new Context(), { dshHome })
+      await expect(reopened.readImage(attachment)).resolves.toEqual(stored)
+      await expect(readFile(reopened.fileHostPath(file))).resolves.toEqual(Buffer.from(fileData))
+      await expect(reopened.readImageRequest(attachment, policy)).resolves.toEqual(initial)
+      await expect(readFile(path)).resolves.toEqual(Buffer.from(initial.data))
+      await expect(readdir(fallbackHome)).resolves.toEqual([])
+    } finally {
+      vi.unstubAllEnvs()
+    }
+  })
+
   it('passes through an in-budget attachment and composes ordered request reads', async () => {
     const attachments = await store()
     const first = await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' })
@@ -81,12 +119,13 @@ describe('local request-image cache', () => {
   })
 
   it('regenerates invalid, oversized, incompatible, or mismatched cached variants', async () => {
-    const attachments = await store()
+    const dshHome = await home()
+    const attachments = new LocalAttachmentStore(new Context(), { dshHome })
     const attachment = await attachments.saveImage({ data: await image(64, 32), mediaType: 'image/png' })
     const policy = { maxPixels: 16 * 16, maxBytes: 4_096 }
     const initial = await attachments.readImageRequest(attachment, policy)
     const hash = String(initial.variantId).slice('sha256:'.length)
-    const path = join(attachments.root, 'request-images', hash.slice(0, 2), hash)
+    const path = join(dshHome, 'cache', 'attachments', 'request-images', hash.slice(0, 2), hash)
     const noisyPixels = new Uint8Array(64 * 64 * 3)
     let state = 0x2545f491
     for (let index = 0; index < noisyPixels.length; index += 1) {

+ 2 - 2
packages/util/home-paths/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/util/home-paths/README.md
-README.md: 2ce9810bd29bdb00f5eb2e09d886f31e2ca3b176
-README.zh.md: 0bd46c223992c37be01876597aa239cfc8eb768d
+README.md: eb595a7c468ac47128d30e95771a1c0905571834
+README.zh.md: 215317cd42d5d72319cc7af008ba2f4dbe1a26fb

+ 4 - 1
packages/util/home-paths/README.md

@@ -29,14 +29,17 @@ Use these helpers wherever a package must agree with the rest of the harness abo
 ### Resolving the home
 
 ```ts
-import { resolveDshHome, dshHomePath } from '@deepseek-ai/dsh-home-paths'
+import { resolveDshHome, dshHomePath, dshCachePath } from '@deepseek-ai/dsh-home-paths'
 
 const home = resolveDshHome()                // configured path, else $DSH_HOME, else ~/.dsh
 const settings = dshHomePath('settings')     // join one child onto the resolved home
+const cache = dshCachePath('models')         // $DSH_HOME/cache/models, default ~/.dsh/cache/models
 ```
 
 An explicit configured path has the highest precedence, then `$DSH_HOME`, then the default `~/.dsh`. An empty or whitespace-only `$DSH_HOME` is treated as unset, so a blank override never resolves the home to the current working directory.
 
+`dshCachePath(...segments)` derives paths from the resolved home's `cache` directory. With no segments it returns the cache directory itself. Pass an initial options object, `dshCachePath({ dshHome: home }, ...segments)`, to use an explicit configured home with the same precedence and tilde expansion. It returns an absolute path without creating directories.
+
 ### Displaying a home
 
 For user-facing paths, render the root symbolically rather than as a machine path: the default home displays as `~/.dsh` and any configured home displays as `$DSH_HOME`. The display form never leaks an absolute machine path.

+ 4 - 1
packages/util/home-paths/README.zh.md

@@ -29,14 +29,17 @@ kind: "package-library"
 ### 解析主目录
 
 ```ts
-import { resolveDshHome, dshHomePath } from '@deepseek-ai/dsh-home-paths'
+import { resolveDshHome, dshHomePath, dshCachePath } from '@deepseek-ai/dsh-home-paths'
 
 const home = resolveDshHome()                // configured path, else $DSH_HOME, else ~/.dsh
 const settings = dshHomePath('settings')     // join one child onto the resolved home
+const cache = dshCachePath('models')         // $DSH_HOME/cache/models, default ~/.dsh/cache/models
 ```
 
 显式配置的路径优先级最高,然后是 `$DSH_HOME`,最后是默认的 `~/.dsh`。空或仅含空白的 `$DSH_HOME` 视为未设置,因此空白的覆盖值绝不会把主目录解析到当前工作目录。
 
+`dshCachePath(...segments)` 从解析出的主目录下的 `cache` 目录派生路径。不传路径段时返回缓存目录本身。传入首个选项对象 `dshCachePath({ dshHome: home }, ...segments)` 可使用显式配置的主目录,遵循相同的优先级与波浪号展开规则。它返回绝对路径,不会创建目录。
+
 ### 展示主目录
 
 面向用户的路径请以符号形式渲染根目录,而不是机器路径:默认主目录显示为 `~/.dsh`,任何已配置的主目录显示为 `$DSH_HOME`。展示形式绝不会泄露机器的绝对路径。

+ 11 - 0
packages/util/home-paths/src/index.ts

@@ -99,6 +99,17 @@ export function dshHomePath(...segments: string[]): string {
   return join(resolveDshHome(), ...segments)
 }
 
+/**
+ * Join path segments onto the resolved Harness home's `cache` directory without creating it; no arguments returns the directory itself.
+ * @param optionsOrSegment - explicit home override, or the first path segment; omission uses the default home resolution.
+ * @param segments - additional path segments after the first child, if any.
+ * @returns the normalized absolute cache path.
+ */
+export function dshCachePath(optionsOrSegment: { dshHome?: string } | string = {}, ...segments: string[]): string {
+  if (typeof optionsOrSegment === 'string') return dshHomePath('cache', optionsOrSegment, ...segments)
+  return join(resolveDshHome(optionsOrSegment.dshHome), 'cache', ...segments)
+}
+
 /**
  * Describe a resolved harness home symbolically for user-facing display.
  *

+ 30 - 0
packages/util/home-paths/tests/home-paths.spec.ts

@@ -7,6 +7,7 @@ import {
   DSH_HOME_DIR_NAME,
   canonicalizeWatchPath,
   defaultDshHome,
+  dshCachePath,
   dshHomeDisplay,
   dshHomePath,
   expandHomePath,
@@ -56,6 +57,34 @@ describe('dsh path helpers', () => {
     expect(dshHomeDisplay('/some/other/root')).toBe('$DSH_HOME')
   })
 
+  it.each([
+    [undefined, join(homedir(), '.dsh')],
+    ['', join(homedir(), '.dsh')],
+    ['   ', join(homedir(), '.dsh')],
+    ['~/env-dsh', join(homedir(), 'env-dsh')],
+    ['./relative-dsh', resolve('./relative-dsh')],
+  ] as const)('resolves cache paths with DSH_HOME=%j', (home, expectedHome) => {
+    vi.stubEnv('DSH_HOME', home)
+    try {
+      expect(dshCachePath()).toBe(join(expectedHome, 'cache'))
+      expect(dshCachePath('models', 'index.json')).toBe(join(expectedHome, 'cache', 'models', 'index.json'))
+    } finally {
+      vi.unstubAllEnvs()
+    }
+  })
+
+  it('resolves configured cache homes before the environment', () => {
+    vi.stubEnv('DSH_HOME', '~/env-dsh')
+    try {
+      expect(dshCachePath({ dshHome: '~/explicit-dsh' })).toBe(join(homedir(), 'explicit-dsh', 'cache'))
+      expect(dshCachePath({ dshHome: './explicit-dsh' }, 'attachments', 'request-images'))
+        .toBe(resolve('./explicit-dsh/cache/attachments/request-images'))
+      expect(dshCachePath({}, 'attachments')).toBe(join(homedir(), 'env-dsh', 'cache', 'attachments'))
+    } finally {
+      vi.unstubAllEnvs()
+    }
+  })
+
   it('canonicalizes a watcher ancestor while preserving a missing suffix', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-watch-path-'))
     const target = join(root, 'target')
@@ -63,6 +92,7 @@ describe('dsh path helpers', () => {
     try {
       await mkdir(target)
       await symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
+      await expect(canonicalizeWatchPath(alias)).resolves.toBe(await realpath(target))
       await expect(canonicalizeWatchPath(join(alias, 'later', 'config.yml'))).resolves.toBe(
         join(await realpath(target), 'later', 'config.yml'),
       )