From 0aeca9c3cef39da0758b9c1af283c6205b853a5d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:19:45 +0800 Subject: [PATCH] fix(windows): make native coverage graph portable --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 ++-- ...26-08-08-native-windows-pull-request-ci.md | 4 ++++ ...08-08-native-windows-pull-request-ci.zh.md | 4 ++++ .../bash/pwsh-local/tests/executor.spec.ts | 12 ++++++++--- .../tests/markdown-incremental.spec.tsx | 2 +- .../tests/workspace-context.spec.ts | 2 +- .../credentials-local/src/index.ts | 13 +++++++++--- .../credentials-local/tests/local.spec.ts | 4 ++-- .../tests/review-fixes.spec.ts | 2 +- .../agent-spine-demo/tests/agent-core.spec.ts | 5 +++-- .../tests/api-proxy-workspace.spec.ts | 2 +- .../sdk/sdk-client/tests/sdk-client.spec.ts | 9 +++++++-- .../session-persistence-jsonl/src/win32.ts | 4 ++-- .../settings-local/tests/concurrency.spec.ts | 2 +- .../settings-local/tests/local.spec.ts | 20 ++++++++++--------- packages/skill/skill-local/README.i18n.yaml | 4 ++-- packages/skill/skill-local/README.md | 2 +- packages/skill/skill-local/README.zh.md | 2 +- packages/skill/skill-local/src/index.ts | 4 +++- .../tests/skill-local-watcher.spec.ts | 1 + .../storage-json/tests/json-backend.spec.ts | 14 ++++++++----- .../tests/real-product.spec.ts | 2 +- .../subagent-codex/tests/real-product.spec.ts | 9 ++++++--- .../subagent/tests/list-children.spec.ts | 2 +- .../tests/user-id.spec.ts | 8 +++----- packages/typert/loader/tests/loader.spec.ts | 6 +++--- .../app-boot/tests/repository-cache.spec.ts | 7 +++++-- .../atomic-write/tests/atomic-write.spec.ts | 4 ++-- packages/util/paths/src/index.ts | 9 +++++++-- scripts/client-tsconfig.spec.ts | 4 +++- 30 files changed, 107 insertions(+), 60 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 562ef5fa9e..698ddd0047 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: a798aa906c2e22bceb021d1a95c081e9fa534d7c -2026-08-08-native-windows-pull-request-ci.zh.md: 58fed00e1f324a8d61af179421c7bc5703c25678 +2026-08-08-native-windows-pull-request-ci.md: 0769a8dc582f48c1baddbb177e2e9921b809e391 +2026-08-08-native-windows-pull-request-ci.zh.md: 7431d917adf2bfd8cc0cf31e9c249b4f28e6caf0 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index a798aa906c..0769a8dc58 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -26,6 +26,10 @@ After the branch incorporated a newer `master`, the next native coverage run fou The next exact-head run exposed one remaining observational built-bin failure: its lifecycle fixtures used `process.kill()` or `subprocess.kill()` to send `SIGTERM`, which unconditionally terminates a Windows target instead of delivering the registered process event for graceful disposal. POSIX acceptance still sends the real signal. On Windows the fixture requests that same registered event from inside the child, directly for a self-terminating probe and through a marker for parent-controlled lifecycle cases, so the assembled shutdown and disposal path remains covered without asserting an operating-system facility that does not exist. That acceptance then exposed the underlying early-shutdown race: a signal could dispose the root after boot returned while fallback HMR watchers were mounting, and the resulting inactive-service error escaped as a boot failure. Post-boot setup now admits work only while the authoritative root fiber is active and contains a concurrent setup error only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. +Running the complete instrumented graph instead of the earlier reduced inventory exposed the remaining cross-platform fixture contracts. Windows path identity now accounts for 8.3 aliases, native separators, Git checkout line endings, cross-drive relative paths, and file URLs before constructing loader symlinks. The JSONL durable-directory helper applies the extended-length namespace to probes and staging creation, real product tests invoke portable executable entries and tolerate bounded Windows handle release, and stress tests retain their workloads with explicit coverage budgets. A credential document or watch path whose deepest existing ancestor is a file now fails `ENOTDIR` on every host, while `skill-local` uses effect-owned persistent Chokidar handles so asynchronous libuv errors are contained instead of escaping the test process. + +POSIX mode bits, chmod-based unreadability, and chmod-based writer-lock refusal do not exist as equivalent Windows facilities. Those acceptance cases remain enforced on POSIX and are skipped on Windows; content, atomic replacement, symlink safety, rollback and recovery through platform-independent filesystem conflicts, and native Windows long-path behavior remain covered. No supported product source is excluded from Windows coverage to accommodate these differences. + Wine-only infrastructure is absent from the supported workflow: there is no apt-cache producer, compatibility script, hoisted snapshot install, Windows Node download, or local `check:windows-wine` command. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) remains historical evidence for its measured latency and fidelity trade-offs, not a current execution path. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 58fed00e1f..7431d917ad 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -26,6 +26,10 @@ Status: implemented 下一次分支头精确运行暴露出观测项中剩余的一项 built-bin 故障:其生命周期 fixture 通过 `process.kill()` 或 `subprocess.kill()` 发送 `SIGTERM`;在 Windows 上,这种调用会无条件终止目标进程,而不会交付为优雅释放所注册的进程事件。POSIX 验收仍发送真实信号。在 Windows 上,fixture 改为从子进程内部请求同一个已注册事件:自终止探测直接请求,由父进程控制的生命周期场景则通过标记请求;因此,完整组装后的关闭与释放路径仍得到覆盖,也无需断言操作系统提供了本不存在的信号机制。该项验收随即暴露出底层的提前关闭竞态:boot 返回后,回退 HMR watcher 仍在挂载,此时信号可能对根 fiber 执行 dispose(资源释放),由此产生的服务未激活错误会逸出并被报告为 boot 失败。boot 后 setup 现在只会在权威根 fiber 仍处于活跃状态时接纳工作;只有当本次调用所记录的信号已取得关闭流程所有权时,才会隔离并发 setup 错误,无关的 HMR 故障仍会响亮失败。 +运行完整的覆盖率插桩图而非此前缩减的清单后,剩余的跨平台 fixture 契约也显现出来。Windows 路径标识现在会在比较或构造 loader 符号链接前处理 8.3 别名、原生分隔符、Git 检出换行、跨盘符相对路径与文件 URL。JSONL 持久目录辅助函数会对探测与临时目录创建应用扩展长度命名空间;真实产品测试会调用可移植的可执行入口,并以有界重试容纳 Windows 句柄释放;压力测试则保留原工作负载并获得显式的覆盖率预算。如果凭据文档或监听路径最深的现有祖先是文件,所有宿主现在都会返回 `ENOTDIR`;`skill-local` 同时改用由 effect 拥有的持久 Chokidar 句柄,使异步 libuv 错误得到收束,不再逸出测试进程。 + +POSIX 模式位、基于 chmod 的不可读状态和基于 chmod 的 writer lock 拒绝在 Windows 上没有等价机制。这些验收场景继续在 POSIX 上强制执行,并在 Windows 上跳过;内容、原子替换、符号链接安全、通过平台无关文件系统冲突验证的回滚与恢复,以及原生 Windows 长路径行为仍保有覆盖。没有任何受支持的产品源码为适应这些差异而从 Windows 覆盖率中排除。 + 受支持的工作流不含 Wine 专属基础设施:不存在 apt 缓存生产者、兼容性脚本、对仓库快照执行的 hoisted 安装、Windows Node 下载或本地 `check:windows-wine` 命令。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)仍作为其实测延迟与保真度取舍的历史证据,而非当前执行路径。 ## 曾考虑的替代方案 diff --git a/packages/bash/pwsh-local/tests/executor.spec.ts b/packages/bash/pwsh-local/tests/executor.spec.ts index c7da16c44c..db15f009bd 100644 --- a/packages/bash/pwsh-local/tests/executor.spec.ts +++ b/packages/bash/pwsh-local/tests/executor.spec.ts @@ -9,7 +9,7 @@ * writes CRLF on Windows, so exact text assertions normalize line endings. */ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawnSync } from 'node:child_process' @@ -33,7 +33,9 @@ const lf = (text: string): string => text.replace(/\r\n/g, '\n') /** Case-insensitive path equality on Windows (Get-Location may re-case the drive). */ function samePath(actual: string, expected: string): boolean { - const norm = (value: string) => (process.platform === 'win32' ? value.toLowerCase() : value) + const norm = (value: string) => ( + process.platform === 'win32' ? realpathSync.native(value).toLowerCase() : value + ) return norm(actual) === norm(expected) } @@ -72,7 +74,11 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => it('falls through an empty configured path to platform resolution', () => { // SystemRoot points at a non-existent tree so the Windows PowerShell 5.1 // fallback candidate cannot exist either. - expect(resolvePwshPath('', { PATH: 'P:\\Store', SystemRoot: 'S:\\no-windows' }, 'win32')).toBe('pwsh') + expect(resolvePwshPath('', { + PATH: 'P:\\Store', + ProgramFiles: 'P:\\no-program-files', + SystemRoot: 'S:\\no-windows', + }, 'win32')).toBe('pwsh') }) it('returns pwsh on non-Windows platforms regardless of the environment', () => { diff --git a/packages/client/ui-primitives/tests/markdown-incremental.spec.tsx b/packages/client/ui-primitives/tests/markdown-incremental.spec.tsx index b36ce8c674..4397aea124 100644 --- a/packages/client/ui-primitives/tests/markdown-incremental.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-incremental.spec.tsx @@ -58,7 +58,7 @@ const STREAM_DOC = [ describe('incremental streaming rendering', () => { for (const chunkSize of [1, 3, 7, 16]) { - it(`matches a fresh render at every prefix (chunk=${chunkSize})`, () => { + it(`matches a fresh render at every prefix (chunk=${chunkSize})`, { timeout: 20_000 }, () => { const live = render() for (let end = chunkSize; end < STREAM_DOC.length + chunkSize; end += chunkSize) { const prefix = STREAM_DOC.slice(0, Math.min(end, STREAM_DOC.length)) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 0af12596cc..ee8a9597f6 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -4122,7 +4122,7 @@ describe('dynamic nested workspace context injection', () => { await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith('workspace instruction refresh failed: %o', failure) - }) + }, { timeout: 10_000 }) } finally { await ctx.fiber.dispose() } diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index 67b598d7e0..f6aba278cc 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -97,16 +97,23 @@ const GROUP_OTHER_BITS = 0o077 * here — so the check is skipped rather than faked, and the file's protection * there is whatever the create and replace APIs express. * @param filename - absolute path of the document. - * @throws when the file exists with group or other permission bits set. + * @throws when the path hierarchy is invalid or the file exists with group or other permission bits set. */ async function assertOwnerOnly(filename: string): Promise { - /* v8 ignore next -- native Windows coverage exercises the skip; POSIX covers the check */ - if (process.platform === 'win32') return + /* v8 ignore start -- native Windows coverage exercises this path; POSIX covers mode enforcement */ + if (process.platform === 'win32') { + // Windows has no POSIX mode bits, but it reports a file-as-parent as + // ordinary ENOENT; canonicalization preserves the invalid-path failure. + await canonicalizeWatchPath(filename) + return + } + /* v8 ignore stop */ let mode: number try { mode = (await stat(filename)).mode } catch (error) { if (!isENOENT(error)) throw error + await canonicalizeWatchPath(filename) return } const offending = mode & GROUP_OTHER_BITS diff --git a/packages/credentials/credentials-local/tests/local.spec.ts b/packages/credentials/credentials-local/tests/local.spec.ts index 43e42cd53f..7130d85db4 100644 --- a/packages/credentials/credentials-local/tests/local.spec.ts +++ b/packages/credentials/credentials-local/tests/local.spec.ts @@ -168,7 +168,7 @@ describe('layer ladder', () => { expect(await stored.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' }) }) - it('refuses a document other OS users can read', async () => { + it.skipIf(process.platform === 'win32')('refuses a document other OS users can read', async () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') await writeFile(path, 'DSH_CRED_TEST: leaked\n', { mode: 0o644 }) @@ -274,7 +274,7 @@ describe('document writes', () => { const seen = updates(ctx) await ctx.credentials.set(KEY, 'sk-fresh') expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST: sk-fresh\n') - expect((await stat(path)).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600) expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'sk-fresh', source: 'file' }) expect(seen).toEqual([KEY]) }) diff --git a/packages/credentials/credentials-local/tests/review-fixes.spec.ts b/packages/credentials/credentials-local/tests/review-fixes.spec.ts index fcec7fceb9..b7839cf538 100644 --- a/packages/credentials/credentials-local/tests/review-fixes.spec.ts +++ b/packages/credentials/credentials-local/tests/review-fixes.spec.ts @@ -78,7 +78,7 @@ describe('read-modify-write', () => { const home = join(dir, 'home') const ctx = await boot({ path: join(home, '.credentials.yaml'), watch: false }) await ctx.credentials.set(ALPHA, 'one') - expect((await stat(home)).mode & 0o777).toBe(0o700) + if (process.platform !== 'win32') expect((await stat(home)).mode & 0o777).toBe(0o700) }) }) diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 3224487b60..b37ec9e66e 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { join } from 'node:path' +import { join, sep } from 'node:path' import { tmpdir } from 'node:os' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' @@ -491,7 +491,8 @@ describe('dsh-agent-spine-demo bundle', () => { callId: event.data.message.source.callId, isError: result.isError, text: result.content.map(block => block.type === 'text' ? block.text : '').join('\n') - .replaceAll(root, '{{cwd}}'), + .replaceAll(root, '{{cwd}}') + .replaceAll(sep, '/'), }] } return [] diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index aa560bdf58..23e2bf5f11 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -59,7 +59,7 @@ function stubAgent(session: Session): Agent { /** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */ async function harness( - workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), + workspaceRoot = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null }, extras: { openPath?: (path: string, signal: AbortSignal) => Promise } = {}, ) { diff --git a/packages/sdk/sdk-client/tests/sdk-client.spec.ts b/packages/sdk/sdk-client/tests/sdk-client.spec.ts index 8b76af755e..a710a44c1d 100644 --- a/packages/sdk/sdk-client/tests/sdk-client.spec.ts +++ b/packages/sdk/sdk-client/tests/sdk-client.spec.ts @@ -173,7 +173,8 @@ describe('DeepSeekHarness', () => { it('resolves a relative launch cwd to an absolute workspace before the handshake', async () => { // vitest workers forbid chdir, so derive a RELATIVE path from the real // process cwd to a temp worker dir; resolution is lexical either way. - const dir = await tempDir('sdk-client-relcwd-') + const dir = await mkdtemp(join(process.cwd(), '.dsh-sdk-client-relcwd-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) const recordFile = join(dir, 'init.jsonl') const inner = join(dir, 'worker') await mkdir(inner) @@ -332,7 +333,11 @@ describe('HarnessClient', () => { )) await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }) await client.close() - expect((await stat(sigtermFile)).isFile()).toBe(true) + if (process.platform === 'win32') { + await expect(stat(sigtermFile)).rejects.toMatchObject({ code: 'ENOENT' }) + } else { + expect((await stat(sigtermFile)).isFile()).toBe(true) + } }) it('escalates to SIGKILL when the runtime traps SIGTERM too', async () => { diff --git a/packages/session-persistence/session-persistence-jsonl/src/win32.ts b/packages/session-persistence/session-persistence-jsonl/src/win32.ts index 5b2b034574..9688eb6bcd 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/win32.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/win32.ts @@ -91,7 +91,7 @@ function isEEXIST(error: unknown): boolean { async function assertDirectory(path: string): Promise { try { - const info = await stat(path) + const info = await stat(toNamespacedPath(path)) if (info.isDirectory()) return true const error = new Error(`path exists but is not a directory: ${path}`) as NodeJS.ErrnoException error.code = 'ENOTDIR' @@ -141,7 +141,7 @@ export async function ensureDurableDirectoryWin32(target: string): Promise async function createLeafDirectoryWin32(parent: string, target: string): Promise { // Keep the staging component independent of the target basename so a legal // 255-byte target component does not make mkdtemp's sibling name too long. - const staging = await mkdtemp(join(parent, '.dsh-mkdir-')) + const staging = await mkdtemp(toNamespacedPath(join(parent, '.dsh-mkdir-'))) try { await publishNewFileWin32(staging, target) } catch (error) { diff --git a/packages/settings/settings-local/tests/concurrency.spec.ts b/packages/settings/settings-local/tests/concurrency.spec.ts index 1c6996434a..5df2179219 100644 --- a/packages/settings/settings-local/tests/concurrency.spec.ts +++ b/packages/settings/settings-local/tests/concurrency.spec.ts @@ -86,7 +86,7 @@ describe('writer lock', () => { expect(await readFile(lockPath, 'utf8')).toBe('slow-holder\n') }, 10_000) - it('surfaces a non-contention lock failure as the write error', async () => { + it.skipIf(process.platform === 'win32')('surfaces a non-contention lock failure as the write error', async () => { const dir = await tempDir() const path = join(dir, 'settings.yaml') const ctx = await boot({ path, watch: false }) diff --git a/packages/settings/settings-local/tests/local.spec.ts b/packages/settings/settings-local/tests/local.spec.ts index 7df16b48d3..bcf9bfc276 100644 --- a/packages/settings/settings-local/tests/local.spec.ts +++ b/packages/settings/settings-local/tests/local.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import z from 'schemastery' -import { chmod, lstat, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises' +import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' @@ -67,7 +67,7 @@ describe('boot and reads', () => { await expect(ctx.settings.prepareDocument()).resolves.toBe(path) expect(await readFile(path, 'utf8')).toBe('') - expect((await stat(path)).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600) expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 }) }) @@ -128,7 +128,7 @@ describe('boot and reads', () => { expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 }) }) - it('fails loud at boot when the document exists but is unreadable', async () => { + it.skipIf(process.platform === 'win32')('fails loud at boot when the document exists but is unreadable', async () => { const dir = await tempDir() const path = join(dir, 'settings.yaml') await writeFile(path, 'ui-theme:\n theme: light\n') @@ -168,7 +168,7 @@ describe('persist', () => { const written = await readFile(path, 'utf8') expect(written).toContain('theme: light') - expect((await stat(path)).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600) // Atomic replace leaves no temp artifact behind. expect((await readdir(dir)).sort()).toEqual(['settings.yaml']) }) @@ -203,7 +203,7 @@ describe('persist', () => { expect(await readFile(victim, 'utf8')).toBe('precious') expect((await lstat(path)).isSymbolicLink()).toBe(false) - expect((await stat(path)).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600) expect(await readFile(path, 'utf8')).toContain('theme: light') }) @@ -337,16 +337,18 @@ describe('persist', () => { expect(written).toEqual({ 'ui-theme': { theme: 'light' } }) }) - it('rejects and leaves no temp residue when the directory turns unwritable', async () => { + it('rejects and recovers when the document path becomes a directory', async () => { const dir = await tempDir() const path = join(dir, 'settings.yaml') + const backup = join(dir, 'settings.committed.yaml') await writeFile(path, 'ui-theme:\n theme: light\n') const ctx = await boot({ path, watch: false }) const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) - await chmod(dir, 0o500) - cleanups.push(() => chmod(dir, 0o700)) + await rename(path, backup) + await mkdir(path) await expect(scope.update({ theme: 'dark' })).rejects.toThrow() - await chmod(dir, 0o700) + await rm(path, { recursive: true }) + await rename(backup, path) expect((await readdir(dir)).sort()).toEqual(['settings.yaml']) expect(scope.get().theme).toBe('light') // The failed persist must not poison the document write chain. diff --git a/packages/skill/skill-local/README.i18n.yaml b/packages/skill/skill-local/README.i18n.yaml index f8fde193d4..73bfe42ec1 100644 --- a/packages/skill/skill-local/README.i18n.yaml +++ b/packages/skill/skill-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/skill/skill-local/README.md -README.md: 6d3d97ea34308ccc920d0b42c5a220dd7a9d6d73 -README.zh.md: 9948369774bc08a4ddbd4e518b53134ddd7c6882 +README.md: dc2e97f89349a85ce548e5f6f1b01408eb32a293 +README.zh.md: 0c17d9fb77af3df2071613c2c6b8fc15086581a7 diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index 6d3d97ea34..dc2e97f893 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -48,7 +48,7 @@ Existing skill roots are watched with Chokidar. Before opening a native watcher, A root that does not exist is followed from the nearest existing ancestor one missing path segment at a time. The next segment is probed with `fs.watchFile`; once `.agents`, `skills`, or the configured root appears, observation advances until Chokidar can attach to the real root. Root deletion reverses this process, so deleting and recreating an entire skills directory remains observable. Project-scoped watchers are bounded by `watchMaxProjects`; revisiting an evicted project reattaches observation during discovery. -The first-party filesystem `write` and `edit` tools also synchronously invalidate the provider through `fs/observed` when their target could affect a watched skill entry. This fast path makes the next model step observe its own filesystem mutation without waiting for the host watcher. External IDE, Git, shell, and process changes rely on Chokidar or the missing-path probe. Startup/runtime watcher failures are logged and retried. Discovery still scans readable roots and returns their candidates for direct loading, but marks the observation incomplete so it is not cached or published as an authoritative model catalog. Effect teardown closes every watcher and contains late callbacks. +The first-party filesystem `write` and `edit` tools also synchronously invalidate the provider through `fs/observed` when their target could affect a watched skill entry. This fast path makes the next model step observe its own filesystem mutation without waiting for the host watcher. External IDE, Git, shell, and process changes rely on Chokidar or the missing-path probe. Existing-root watchers remain persistent until effect teardown so Chokidar owns asynchronous native error events; startup/runtime watcher failures are logged and retried. Discovery still scans readable roots and returns their candidates for direct loading, but marks the observation incomplete so it is not cached or published as an authoritative model catalog. Effect teardown closes every watcher and contains late callbacks. ## Skill Format diff --git a/packages/skill/skill-local/README.zh.md b/packages/skill/skill-local/README.zh.md index 9948369774..0c17d9fb77 100644 --- a/packages/skill/skill-local/README.zh.md +++ b/packages/skill/skill-local/README.zh.md @@ -48,7 +48,7 @@ 不存在的根会从最近的现有祖先开始,每次沿一个缺失路径段跟踪。系统使用 `fs.watchFile` 探测下一段;当 `.agents`、`skills` 或已配置的根出现后,观察会逐级推进,直至 Chokidar 可以附加到真实根。根删除时,该过程反向执行,因此删除再重建整个 skills 目录仍可被观察到。按项目划分的 watcher 数量受 `watchMaxProjects` 限制;再次访问已被驱逐的项目时,发现阶段会重新附加观察。 -如果第一方文件系统 `write` 和 `edit` 工具的目标可能影响受监视的 skill 条目,它们还会通过 `fs/observed` 同步使提供方失效。这条快速路径让模型的下一个步骤无需等待宿主 watcher,即可观察到自身的文件系统变更。外部 IDE、Git、shell 和进程产生的变更依赖 Chokidar 或缺失路径探测。watcher 启动或运行时失败会被记录并触发重试。发现过程仍会扫描可读根目录,并返回其候选项供直接加载,但会将观测标记为不完整,因此不会缓存,也不会作为权威模型目录发布。effect 释放会关闭所有 watcher,并收束延迟回调。 +如果第一方文件系统 `write` 和 `edit` 工具的目标可能影响受监视的 skill 条目,它们还会通过 `fs/observed` 同步使提供方失效。这条快速路径让模型的下一个步骤无需等待宿主 watcher,即可观察到自身的文件系统变更。外部 IDE、Git、shell 和进程产生的变更依赖 Chokidar 或缺失路径探测。现有根的 watcher 会保持持久状态直至 effect 释放,使 Chokidar 能够接管异步原生错误事件;watcher 启动或运行时失败会被记录并触发重试。发现过程仍会扫描可读根目录,并返回其候选项供直接加载,但会将观测标记为不完整,因此不会缓存,也不会作为权威模型目录发布。effect 释放会关闭所有 watcher,并收束延迟回调。 ## skill 格式 diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index f06538706a..5b11e71f26 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -487,7 +487,9 @@ class SkillWatchManager { private async openRootWatcher(state: RootWatchState, mode: Extract): Promise { const watcher = chokidar.watch(mode.anchor, { - persistent: false, + // Chokidar owns late native fs.watch errors only for persistent watchers; + // this provider's effect explicitly closes every handle at teardown. + persistent: true, ignoreInitial: true, depth: 1, followSymlinks: this.config.followSymlinks, diff --git a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts index 5f949128f3..8b2a62e842 100644 --- a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -132,6 +132,7 @@ describe('skill-local watcher failures', () => { expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['canonical-skill']) expect(watcherHarness.watchers[0]?.path).toBe(await realpath(root)) + expect(watcherHarness.watchers[0]?.options.persistent).toBe(true) await fiber.dispose() }) diff --git a/packages/storage/storage-json/tests/json-backend.spec.ts b/packages/storage/storage-json/tests/json-backend.spec.ts index 2f2fff90fc..b8d37eabb9 100644 --- a/packages/storage/storage-json/tests/json-backend.spec.ts +++ b/packages/storage/storage-json/tests/json-backend.spec.ts @@ -1,4 +1,4 @@ -import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, describe, expect, it } from 'vitest' @@ -88,19 +88,23 @@ describe('json backend specifics', () => { const unit = await backend.kv.open(descriptor) await unit.putRecord('t', 'k', { v: 'committed' }) await unit.setGlobal({ g: 'committed' }) - // Make every publish fail: revoke write permission on the root. - await chmod(root, 0o500) + const path = join(root, 'shape.json') + const backup = join(root, 'shape.committed.json') + // A directory at the publish target rejects atomic replacement on every host. + await rename(path, backup) + await mkdir(path) await expect(unit.putRecord('t', 'k', { v: 'rejected' })).rejects.toThrow() await expect(unit.putRecord('t', 'k2', { v: 'also rejected' })).rejects.toThrow() await expect(unit.deleteRecord('t', 'k')).rejects.toThrow() await expect(unit.setGlobal({ g: 'rejected' })).rejects.toThrow() - await chmod(root, 0o700) + await rm(path, { recursive: true }) + await rename(backup, path) const snapshot = await unit.loadAll() expect(snapshot.tables['t']).toEqual({ k: { v: 'committed' } }) expect(snapshot.global).toEqual({ g: 'committed' }) // The next successful publish must not carry rejected writes to disk. await unit.putRecord('t', 'k3', { v: 'later' }) - const text = await readFile(join(root, 'shape.json'), 'utf8') + const text = await readFile(path, 'utf8') expect(text).not.toContain('rejected') await backend.close() }) diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index f76b4038f6..240cfe6b0a 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -90,7 +90,7 @@ afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) await Promise.all(fixtures.splice(0).map(fixture => fixture.close())) for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) } observedSdkMessages.length = 0 }) diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index 5f73adaf7e..badab946e6 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -27,6 +27,7 @@ import { const execFileAsync = promisify(execFile) const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url))) const codexBinDir = join(packageRoot, 'node_modules', '.bin') +const codexEntry = join(packageRoot, 'node_modules', '@openai', 'codex', 'bin', 'codex.js') const codexPackage = JSON.parse(readFileSync( join(packageRoot, 'node_modules', '@openai', 'codex', 'package.json'), 'utf8', @@ -40,7 +41,7 @@ afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) await Promise.all(fixtures.splice(0).map(fixture => fixture.close())) for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) } }) @@ -147,7 +148,7 @@ describe('real @openai/codex 0.146.0 product', () => { { kind: 'complete', text: sentinel }, ]) expect(codexPackage.version).toBe('0.146.0') - const version = await execFileAsync(join(codexBinDir, 'codex'), ['--version'], { + const version = await execFileAsync(process.execPath, [codexEntry, '--version'], { env: { ...process.env, ...harness.env }, }) expect(version.stdout.trim()).toBe('codex-cli 0.146.0') @@ -178,7 +179,9 @@ describe('real @openai/codex 0.146.0 product', () => { kind: 'functionCall', name: 'exec_command', arguments: { - cmd: 'touch approval-side-effect', + cmd: process.platform === 'win32' + ? 'cmd /c type nul > approval-side-effect' + : 'touch approval-side-effect', sandbox_permissions: 'require_escalated', justification: 'exercise the unattended approval boundary', }, diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index b15d1f321f..fa2db5fd04 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -1078,7 +1078,7 @@ describe('SubagentService.listDescendants', () => { }]) }) - it('discovers continuable descendants below ordinary and one-shot intermediates', async () => { + it('discovers continuable descendants below ordinary and one-shot intermediates', { timeout: 20_000 }, async () => { const { ctx, parent } = await setup([textResponse('one shot')]) // An ordinary fork has no descriptor: omitted itself, subtree still walked. const fork = ctx.sessions.fork(parent.session, undefined, SessionId('plain-fork')) diff --git a/packages/telemetry/session-telemetry-otel/tests/user-id.spec.ts b/packages/telemetry/session-telemetry-otel/tests/user-id.spec.ts index f4d9505546..600f0927a6 100644 --- a/packages/telemetry/session-telemetry-otel/tests/user-id.spec.ts +++ b/packages/telemetry/session-telemetry-otel/tests/user-id.spec.ts @@ -1,4 +1,4 @@ -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -17,7 +17,6 @@ function tempHome(): string { afterEach(() => { for (const dir of dirs.splice(0)) { - chmodSync(dir, 0o700) rmSync(dir, { recursive: true, force: true }) } }) @@ -69,11 +68,10 @@ describe('getOrCreateAnonymousUserId', () => { expect(id).toBe(winner) }) - it('returns a usable id when the home is unwritable, without persisting', () => { + it('returns a usable id when the home cannot contain files, without persisting', () => { const home = tempHome() const blocked = join(home, 'blocked') - mkdirSync(blocked) - chmodSync(blocked, 0o500) + writeFileSync(blocked, 'occupied\n') const id = getOrCreateAnonymousUserId({ env: { DSH_HOME: blocked } }) expect(id).toMatch(UUID) expect(existsSync(join(blocked, USER_ID_FILE_NAME))).toBe(false) diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index 750cc92e57..af5d451351 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { pathToFileURL } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' @@ -114,9 +114,9 @@ async function boot(): Promise { async function linkZod(base: string): Promise { const { symlink } = await import('node:fs/promises') const target = join(base, 'node_modules', 'zod') - const source = new URL(import.meta.resolve('zod/package.json')).pathname.replace(/\/package\.json$/, '') + const source = fileURLToPath(new URL('.', import.meta.resolve('zod/package.json'))) await mkdir(join(base, 'node_modules'), { recursive: true }) - await symlink(source, target, 'dir') + await symlink(source, target, process.platform === 'win32' ? 'junction' : 'dir') } function mountTypertLoader(ctx: Context, config: typertLoader.Config = {}): ReturnType { diff --git a/packages/ui/app-boot/tests/repository-cache.spec.ts b/packages/ui/app-boot/tests/repository-cache.spec.ts index b7d80470b8..1ba73cf1df 100644 --- a/packages/ui/app-boot/tests/repository-cache.spec.ts +++ b/packages/ui/app-boot/tests/repository-cache.spec.ts @@ -11,6 +11,9 @@ import { BUNDLED_PNPM_VERSION, RepositoryCache, type RepositoryInstall } from '@ const execFileAsync = promisify(execFile) const roots: string[] = [] +/** Normalize Git's platform checkout line endings for source-content assertions. */ +const lf = (text: string): string => text.replace(/\r\n/g, '\n') + async function temporaryRoot(name: string): Promise { const root = await mkdtemp(join(tmpdir(), `cordis-${name}-`)) roots.push(root) @@ -151,8 +154,8 @@ describe('RepositoryCache', () => { const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier) await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent\n') await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply') - await expect(readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8')) - .resolves.toBe('repository skill source\n') + expect(lf(await readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8'))) + .toBe('repository skill source\n') await expect(readFile(join(installed, 'package.json'), 'utf8')) .resolves.toContain('repository-plugin-fixture') }) diff --git a/packages/util/atomic-write/tests/atomic-write.spec.ts b/packages/util/atomic-write/tests/atomic-write.spec.ts index 2bc9d3ab6a..a917fd13d9 100644 --- a/packages/util/atomic-write/tests/atomic-write.spec.ts +++ b/packages/util/atomic-write/tests/atomic-write.spec.ts @@ -14,7 +14,7 @@ describe('writeFileAtomic', () => { const target = join(dir, 'nested', 'deep', 'doc.yaml') await writeFileAtomic(target, 'a: 1\n', { mode: 0o600 }) expect(await readFile(target, 'utf8')).toBe('a: 1\n') - expect((await stat(target)).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') expect((await stat(target)).mode & 0o777).toBe(0o600) }) it('replaces existing content and narrows a wider-permission file to the stated mode', async () => { @@ -23,7 +23,7 @@ describe('writeFileAtomic', () => { await writeFile(target, 'old', { mode: 0o644 }) await writeFileAtomic(target, 'new', { mode: 0o600 }) expect(await readFile(target, 'utf8')).toBe('new') - expect((await stat(target)).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') expect((await stat(target)).mode & 0o777).toBe(0o600) }) it('replaces a symlinked target itself without writing through to the referent', async () => { diff --git a/packages/util/paths/src/index.ts b/packages/util/paths/src/index.ts index 591158d7c0..dc8e3677d0 100644 --- a/packages/util/paths/src/index.ts +++ b/packages/util/paths/src/index.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-paths */ -import { realpath } from 'node:fs/promises' +import { opendir, realpath } from 'node:fs/promises' import { homedir } from 'node:os' import { basename, dirname, join, resolve } from 'node:path' @@ -32,7 +32,12 @@ export async function canonicalizeWatchPath(path: string): Promise { const missing: string[] = [] while (true) { try { - return join(await realpath(current), ...missing.reverse()) + const canonical = await realpath(current) + if (missing.length > 0) { + const directory = await opendir(canonical) + await directory.close() + } + return join(canonical, ...missing.reverse()) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error const parent = dirname(current) diff --git a/scripts/client-tsconfig.spec.ts b/scripts/client-tsconfig.spec.ts index ed445aa80f..8e82e52353 100644 --- a/scripts/client-tsconfig.spec.ts +++ b/scripts/client-tsconfig.spec.ts @@ -1,7 +1,7 @@ /** Regression coverage for source declarations owned by the client test aggregate. */ import { existsSync, readdirSync } from 'node:fs' -import { resolve } from 'node:path' +import { resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' import ts from 'typescript' import { describe, expect, it } from 'vitest' @@ -14,6 +14,7 @@ function clientCssDeclarations(): string[] { .filter(entry => entry.isDirectory()) .map(entry => resolve(clientRoot, entry.name, 'src/css-modules.d.ts')) .filter(existsSync) + .map(file => file.replaceAll(sep, '/')) .sort() } @@ -26,6 +27,7 @@ describe('client TypeScript aggregate', () => { } const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, root) const loaded = parsed.fileNames + .map(file => file.replaceAll(sep, '/')) .filter(file => file.endsWith('/src/css-modules.d.ts')) .sort() expect(loaded).toEqual(clientCssDeclarations())