ソースを参照

Merge pull request #2144 from deepseek-harness/worktree/fix-windows-preset-ci

fix(agent-presets): repair Windows preset CI
Yichen Jiang 1 ヶ月 前
コミット
86bcd3af47

+ 2 - 0
apps/web/tests/snapshots/steer-all/mid-steer.expected.md

@@ -1,6 +1,8 @@
 - banner:
   - navigation "Session hierarchy":
     - button "Use the ask_user_question tool to" [disabled]
+  - img
+  - text: 标准模式
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 2 - 0
apps/web/tests/snapshots/steer-all/settled.expected.md

@@ -1,6 +1,8 @@
 - banner:
   - navigation "Session hierarchy":
     - button "Use the ask_user_question tool to" [disabled]
+  - img
+  - text: 标准模式
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 1 - 0
apps/web/tests/steering.e2e.ts

@@ -314,6 +314,7 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
     await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
+    await page.getByText('标准模式', { exact: true }).waitFor({ timeout: 10_000 })
   }, 120_000)
 
   afterAll(async () => {

+ 2 - 2
packages/preset/agent-presets/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/preset/agent-presets/README.md
-README.md: ed640cf053ac595dfb9c20c226f3c2ff34db93f6
-README.zh.md: 4e6fc0a4cf0db4b14b136cbad9f73eee64d9c170
+README.md: b6d469b26a0254adc654e5cc49d3df2d10817b2d
+README.zh.md: 60c7bc695c27bf2c0169a0e405aa84aa711b9b21

+ 2 - 0
packages/preset/agent-presets/README.md

@@ -55,6 +55,8 @@ A row's **package name** resolves from the host composition, not from the preset
 
 A **relative** path still resolves from the preset's own directory, so a preset's own plugin files and skill directories travel with it.
 
+An **absolute** filesystem path keeps its own location. The mount converts it to a `file:` URL before ESM import so POSIX paths and Windows drive-letter or UNC paths use a specifier Node accepts.
+
 ### Display metadata
 
 A preset may publish display text in an optional `preset.yml` beside its composition:

+ 2 - 0
packages/preset/agent-presets/README.zh.md

@@ -55,6 +55,8 @@ agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有
 
 **相对**路径仍从 preset 自身的目录解析,因此 preset 自带的插件文件与 skill 目录会随它一同迁移。
 
+**绝对**文件系统路径则保留其自身位置。挂载会先将它转换为 `file:` URL 再交给 ESM 导入,从而使 POSIX 路径和 Windows 盘符或 UNC 路径都采用 Node 能够接受的说明符。
+
 ### 展示用元信息
 
 preset 可以在组装文件旁的可选 `preset.yml` 里发布展示文本:

+ 9 - 4
packages/preset/agent-presets/src/mount.ts

@@ -14,6 +14,7 @@
  * @module @deepseek-ai/dsh-agent-presets/mount
  */
 
+import { isAbsolute } from 'node:path'
 import { pathToFileURL } from 'node:url'
 import { Context, type Fiber } from 'cordis'
 import { Include } from '@cordisjs/plugin-include'
@@ -69,21 +70,25 @@ class PresetTree extends Include {
    * where Node's upward `node_modules` walk never reaches the harness's own
    * dependencies, so every `@deepseek-ai/dsh-*` row would fail to import. The
    * mount records the host composition's base instead, which is inside the
-   * installed harness, and bare names resolve from there.
+   * installed harness, and bare names resolve from there. An absolute
+   * filesystem path names neither base and becomes a file URL before Node's
+   * ESM loader receives it, which is required for drive-letter paths on
+   * Windows.
    * @param name - the module specifier from the row.
    * @param getOuterStack - the loader's stack composer for import diagnostics.
    * @returns the imported module, or the `cordis:` builtin.
    */
   override import(name: string, getOuterStack?: () => string[]): unknown {
+    const specifier = isAbsolute(name) ? pathToFileURL(name).href : name
     const base = harnessBase.get(this.config)
     /* v8 ignore next -- every PresetTree is constructed by `mountPreset`, which records the base first */
-    if (base === undefined) return super.import(name, getOuterStack)
+    if (base === undefined) return super.import(specifier, getOuterStack)
     if (name.startsWith('.') || name.startsWith('cordis:')) return super.import(name, getOuterStack)
     const internal = this.ctx.loader.internal
     /* v8 ignore next -- Node always supplies the internal module loader; the branch keeps a
        hypothetical embedder from losing the row's name in a resolution error. */
-    if (internal === undefined) return super.import(name, getOuterStack)
-    return internal.import(name, base, {})
+    if (internal === undefined) return super.import(specifier, getOuterStack)
+    return internal.import(specifier, base, {})
   }
 
   /**

+ 10 - 7
packages/preset/agent-presets/tests/authoring.spec.ts

@@ -65,20 +65,23 @@ describe('copying a preset', () => {
     expect(listed.find(preset => preset.id === 'mine')?.trust).toBe('user')
   })
 
-  it('copies the whole directory, execute bits kept and group/other stripped', async () => {
+  it('copies the whole directory and tightens POSIX modes', async () => {
     await seedPreset(userRoot, 'source', {
       extras: { 'skills/demo/SKILL.md': '# demo\n', 'skills/demo/run.sh': '#!/bin/sh\n' },
     })
-    await chmod(join(userRoot, 'source', 'skills', 'demo', 'run.sh'), 0o755)
+    if (process.platform !== 'win32') {
+      await chmod(join(userRoot, 'source', 'skills', 'demo', 'run.sh'), 0o755)
+    }
 
     await ctx.agentPresets.copy('source', 'mine')
 
     expect(await readFile(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'), 'utf8')).toBe('# demo\n')
-    // A preset may ship runnable helpers; the copy keeps them runnable for the
-    // owner while withdrawing the world-readability of the install.
-    expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'run.sh'))).mode & 0o777).toBe(0o700)
-    expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'))).mode & 0o777).toBe(0o600)
-    expect((await stat(join(userRoot, 'mine'))).mode & 0o777).toBe(0o700)
+    // Windows mode bits are synthetic and cannot represent the inherited DACL.
+    if (process.platform !== 'win32') {
+      expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'run.sh'))).mode & 0o777).toBe(0o700)
+      expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'))).mode & 0o777).toBe(0o600)
+      expect((await stat(join(userRoot, 'mine'))).mode & 0o777).toBe(0o700)
+    }
   })
 
   it('keeps the source description but never its name or order', async () => {

+ 27 - 3
packages/preset/agent-presets/tests/discovery.spec.ts

@@ -1,14 +1,37 @@
-import { chmod, mkdtemp, mkdir, writeFile } from 'node:fs/promises'
+import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
 import { fileURLToPath } from 'node:url'
-import { describe, expect, it } from 'vitest'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
 import { COMPOSITION_FILE, discoverPresets, scanRoot } from '@deepseek-ai/dsh-agent-presets'
 
+const fsHarness = vi.hoisted(() => ({
+  nextReadError: undefined as NodeJS.ErrnoException | undefined,
+}))
+
+vi.mock('node:fs/promises', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('node:fs/promises')>()
+  return {
+    ...actual,
+    readFile: (async (path: unknown, ...rest: never[]) => {
+      const error = fsHarness.nextReadError
+      if (error !== undefined) {
+        fsHarness.nextReadError = undefined
+        throw error
+      }
+      return (actual.readFile as (path: unknown, ...args: never[]) => Promise<unknown>)(path, ...rest)
+    }) as typeof actual.readFile,
+  }
+})
+
 const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
 const SYSTEM = { path: join(FIXTURES, 'system'), trust: 'system' as const }
 const USER = { path: join(FIXTURES, 'user'), trust: 'user' as const }
 
+beforeEach(() => {
+  fsHarness.nextReadError = undefined
+})
+
 describe('display order', () => {
   it('puts declared order first, then everything else by id', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-order-'))
@@ -177,10 +200,11 @@ describe('composition health', () => {
     await mkdir(join(root, 'sealed'))
     const path = join(root, 'sealed', COMPOSITION_FILE)
     await writeFile(path, '[]\n')
-    await chmod(path, 0o000)
+    fsHarness.nextReadError = Object.assign(new Error('EACCES: injected read failure'), { code: 'EACCES' })
 
     const [preset] = await scanRoot({ path: root, trust: 'user' })
 
+    expect(fsHarness.nextReadError).toBeUndefined()
     expect(preset?.broken).toMatch(/cannot be read/)
   })
 

+ 47 - 2
packages/preset/agent-presets/tests/mount.spec.ts

@@ -1,4 +1,4 @@
-import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
 import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -11,7 +11,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry from '@deepseek-ai/dsh-tools'
 import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
 import AgentLoop from '@deepseek-ai/dsh-agent-loop'
-import { beforeEach, describe, expect, it } from 'vitest'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
 import AgentPresets, {
   COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, PresetMountError, serviceForAgent,
 } from '@deepseek-ai/dsh-agent-presets'
@@ -84,6 +84,23 @@ 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-'))
+    const presetDir = join(root, 'absolute')
+    const plugin = join(FIXTURES, 'plugins', 'contribute.js')
+    await mkdir(presetDir)
+    await writeFile(
+      join(presetDir, COMPOSITION_FILE),
+      `- id: only\n  name: ${plugin}\n  config:\n    tool: absolute\n`,
+    )
+    const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }] })
+    const imported = vi.spyOn(scoped.loader.internal!, 'import')
+
+    await agentOn(scoped, 'sess-absolute-plugin')
+
+    expect(imported).toHaveBeenCalledWith(pathToFileURL(plugin).href, expect.any(String), {})
+  })
+
   it('gives each session only its own preset\'s tools', async () => {
     const alpha = await agentOn(ctx, 'sess-alpha', 'standard')
     const beta = await agentOn(ctx, 'sess-beta', 'minimal')
@@ -525,6 +542,34 @@ describe('editing a composition file', () => {
     expect(livePresetMounts().filter(mount => mount.presetId === 'raced')).toHaveLength(2)
   })
 
+  it('keeps a newer generation pointer when a stale refresh loses the swap race', async () => {
+    const { scoped, path } = await editable('guarded-refresh')
+    const preset = await scoped.agentPresets.resolve('guarded-refresh')
+    await agentOn(scoped, 'sess-guarded-refresh-seed', 'guarded-refresh')
+    const service = scoped.agentPresets as unknown as {
+      standing: Map<string, Promise<{
+        key: unknown
+        scope: unknown
+        stamp: { mtimeMs: number; size: number }
+      }>>
+      ensureStanding(current: typeof preset): Promise<unknown>
+    }
+    const stalePromise = service.standing.get(preset.id)!
+    const stale = await stalePromise
+    await writeFile(path, rowFor('afterwards'))
+    const { mtimeMs, size } = await stat(path)
+    const newer = { ...stale, stamp: { mtimeMs, size } }
+    const newerPromise = Promise.resolve(newer)
+
+    // `await pending` yields before the guarded delete, letting the winning
+    // refresher replace the pointer deterministically instead of by timing.
+    const refresh = service.ensureStanding(preset)
+    service.standing.set(preset.id, newerPromise)
+
+    expect(await refresh).toBe(newer)
+    expect(service.standing.get(preset.id)).toBe(newerPromise)
+  })
+
   it('hands a host reader the standing key without starting an agent', async () => {
     const { scoped } = await editable('cold-read')