Jelajahi Sumber

Merge pull request #3603 from deepseek-harness/fix/ci-runtime-wheel-windows

fix(boot): normalize absolute plugin paths in patches
Tianyi Cui 2 minggu lalu
induk
melakukan
93d89dd0d5

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-05-patch-plugin-file-urls.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/bug-fix/2026-09-05-patch-plugin-file-urls.md
+2026-09-05-patch-plugin-file-urls.md: 48112483b4a3eef1fefcb774c2d19a2c901ccbdb
+2026-09-05-patch-plugin-file-urls.zh.md: 775a576324dd2856f490cf3ce3ba907965737fc2

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-09-05-patch-plugin-file-urls.md

@@ -0,0 +1,27 @@
+# Agent Note: File URLs for inserted patch plugins
+
+Status: implemented
+
+English | [中文](2026-09-05-patch-plugin-file-urls.zh.md)
+
+## Problem
+
+Node ESM interprets a Windows drive prefix as a URL scheme and treats filename fragments as URL syntax. Passing filesystem paths directly to plugin imports therefore fails for Windows absolute paths and filenames containing `#` or `%`.
+
+## Decision
+
+Patch-file parsing converts native absolute paths and patch-relative `./` or `../` names to file URLs inside `insert` rows and their nested groups. Package specifiers, existing URLs, existing-entry name assertions, and replacement `config` values retain their meaning. Parsing owns this conversion because it knows the originating patch directory before layers are composed.
+
+The optional `HostResolvedRootInclude` import override separately converts absolute paths for callers selecting an installed-host resolver base. Ordinary CLI profiles do not select that override; it cannot substitute for patch-file conversion.
+
+## Alternatives considered
+
+**Convert only the Python fixture with `Path.as_uri()`.** This avoids one failure but leaves user-authored profile and overlay patches exposed.
+
+**Change the shared Loader base.** This loses per-patch provenance and changes bare-package resolution. File URLs preserve the selected local file without changing the resolver base.
+
+## Consequences
+
+Optional and required patch readers share the conversion. Direct Loader imports and children introduced through replacement group configs remain outside its scope; extending those paths requires their own semantics and coverage.
+
+The patch-reader tests verify conversion and real activation. The built SDK acceptance loads an absolute-path overlay plugin with URL-sensitive filename characters and verifies its filesystem marker, initialization, and shutdown.

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-09-05-patch-plugin-file-urls.zh.md

@@ -0,0 +1,27 @@
+# Agent Note: 插入补丁插件的文件 URL
+
+Status: implemented
+
+[English](2026-09-05-patch-plugin-file-urls.md) | 中文
+
+## Problem
+
+Node ESM 会把 Windows 盘符前缀解释为 URL scheme,并把文件名中的片段字符视为 URL 语法。因此,直接将文件系统路径传给插件导入会使 Windows 绝对路径以及包含 `#` 或 `%` 的文件名加载失败。
+
+## Decision
+
+补丁文件解析将 `insert` 条目及其嵌套分组中的本机绝对路径、相对于补丁文件的 `./` 或 `../` 名称转换为文件 URL。包标识符、已有 URL、已有条目的名称断言及替换用的 `config` 值保持原有含义。解析阶段负责转换,因为在合并各层之前它掌握来源补丁的目录。
+
+可选的 `HostResolvedRootInclude` 导入覆写会另外为选择安装宿主解析基址的调用方转换绝对路径。普通 CLI profile 不选择该覆写,因此它不能代替补丁文件转换。
+
+## Alternatives considered
+
+**只在 Python fixture 中使用 `Path.as_uri()`。** 这能避开一次失败,但用户编写的 profile 和 overlay 补丁仍会遇到问题。
+
+**修改共享 Loader 基址。** 这会丢失逐补丁来源信息并改变裸包解析。文件 URL 保留选定的本地文件,无需修改解析基址。
+
+## Consequences
+
+可选与必需补丁读取器共用该转换。直接 Loader 导入及替换分组 config 引入的子条目不在其范围内;扩展这些路径需要各自的语义与覆盖。
+
+补丁读取器测试验证转换与真实激活。构建后 SDK 验收加载文件名含 URL 敏感字符的绝对路径 overlay 插件,并验证其文件系统标记、初始化与关闭。

+ 24 - 9
apps/cli/tests/built-bin.e2e.ts

@@ -424,9 +424,20 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     }
   }, SPAWN_TIMEOUT_MS + 30_000)
 
-  it('serves the SDK protocol through the sdk profile and exits after shutdown', async () => {
+  it('serves the SDK protocol with an absolute-path overlay plugin and exits after shutdown', async () => {
     const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-'))
-    const child = execa(process.execPath, [dshBin, '--profile', 'sdk'], {
+    const pluginPath = join(home, 'plugin #100%.mjs')
+    const marker = join(home, 'plugin-loaded')
+    writeFileSync(pluginPath, [
+      "import { writeFileSync } from 'node:fs'",
+      'export function apply(ctx, config) { writeFileSync(config.marker, "loaded") }',
+      '',
+    ].join('\n'))
+    const patch = join(home, 'absolute.patch.yml')
+    writeFileSync(patch, JSON.stringify([{ insert: [
+      { id: 'absolute-plugin', name: pluginPath, config: { marker } },
+    ] }]))
+    const child = execa(process.execPath, [dshBin, '--profile', 'sdk', '--patch', patch], {
       cwd: home,
       reject: false,
       timeout: SPAWN_TIMEOUT_MS,
@@ -462,7 +473,8 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
         method: 'initialize',
         params: { cwd: home, provider: 'deepseek-official', model: 'deepseek-v4-flash' },
       })}\n`)
-      expect(await response(1)).toMatchObject({
+      const initialized = await response(1)
+      expect(initialized, `${JSON.stringify(initialized)}\n${stderr}`).toMatchObject({
         jsonrpc: '2.0',
         id: 1,
         result: { serverInfo: { name: 'deepseek-harness-sdk-runtime' } },
@@ -470,8 +482,11 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'shutdown' })}\n`)
       expect(await response(2)).toEqual({ jsonrpc: '2.0', id: 2, result: {} })
       const result = await child
-      expect(result.exitCode, `signal=${String(result.signal)}; stderr=${stderr}`).toBe(0)
+      expect(result.timedOut, stderr).toBe(false)
+      expect(result.signal, stderr).toBeUndefined()
+      expect(result.exitCode, stderr).toBe(0)
       expect(stderr).toBe('')
+      expect(readFileSync(marker, 'utf8')).toBe('loaded')
     } finally {
       child.kill('SIGKILL')
       await child
@@ -712,13 +727,12 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       ].join('\n'))
       await waitForFile(fixture.ready)
       expect(readFileSync(configFile, 'utf8')).toBe('2')
-      // Removal reverts: the bundle's inserted row must return to its own
-      // default config, not keep the removed override — the insert-aliasing
-      // regression (a shared patch object mutated in place by a former
-      // generation would make this impossible).
+      // Unlink exercises layer removal without racing Chokidar's change-event
+      // suppression window after the preceding edit. The bundle default must return.
       rmSync(fixture.ready)
-      writeFileSync(profilePatch, '[]\n')
+      rmSync(profilePatch)
       await waitForFile(fixture.ready)
+      expect(existsSync(profilePatch)).toBe(false)
       expect(readFileSync(configFile, 'utf8')).toBe('bundle-default')
       // The home-level user layer ($DSH_HOME/cordis.patch.yml) is live too
       // and outranks the per-profile layer.
@@ -738,6 +752,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       expect(existsSync(fixture.disposed)).toBe(true)
     } finally {
       child.kill('SIGKILL')
+      await child
       rmSync(fixture.home, { recursive: true, force: true })
     }
   }, SPAWN_TIMEOUT_MS + 30_000)

+ 2 - 2
packages/boot/app-boot/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/boot/app-boot/README.md
-README.md: ddbcc900fcbb38e83b35f517f9d38cf0e04927f3
-README.zh.md: 184e1a086d09bf6807fd8fb2d17f7157ba8deaa6
+README.md: 72b10524b6632f90d3bf7852b0504718518a5c33
+README.zh.md: bbbae612a6fdc9c1060902691f4349ca0b9ae806

+ 2 - 0
packages/boot/app-boot/README.md

@@ -54,6 +54,8 @@ Your machine-local preferences also live in the Harness home:
 
 Profiles with `patchReload: live` watch both user patch files: a valid edit recomposes without restart, while a rejected edit leaves the last good app running. A `startup` profile installs neither those watchers nor the launcher's watch-only HMR fallback.
 
+Inserted plugin names may be absolute filesystem paths, file URLs, or package specifiers. Patch loading converts absolute paths and patch-relative `./` or `../` paths to file URLs within `insert` rows and their nested groups; existing-entry name assertions and replacement `config` values remain literal.
+
 ### Previewing the effective configuration
 
 Before you boot, you can print the exact configuration the app will mount: the dump shows the composed entry list with `!!js` expressions verbatim, grouped under comments naming each source file and the patch layers that changed it, as one loadable YAML document. Patches that match no row are reported with their layer label; a missing, unparsable, or invalid config fails the dump.

+ 2 - 0
packages/boot/app-boot/README.zh.md

@@ -54,6 +54,8 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 带 `patchReload: live` 的 profile 会监视两份用户 patch 文件:有效编辑无需重启即可重新组合,被拒绝的编辑则让最后一个可用应用继续运行。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR 回退。
 
+插入条目的插件名可以是绝对文件系统路径、文件 URL 或包标识符。patch 加载会把 `insert` 条目及其嵌套分组中的绝对路径以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL;对已有条目名称的断言及替换用的 `config` 值保持原样。
+
 ### 预览生效配置
 
 启动前,你可以打印应用将挂载的确切配置:dump 会以 `!!js` 表达式原样展示组合后的条目列表,并按注释分组标明每个源文件及其 patch 层,输出是一份可加载的 YAML 文档。未匹配到任何行的 patch 会连同其层标签一起报告;配置缺失、无法解析或字段无效都会使 dump 失败。

+ 2 - 2
packages/boot/app-boot/src/index.ts

@@ -325,11 +325,11 @@ export function loadOverlayPatches(binName: string, file: string): PatchOptions[
   return parsePatchList(binName, file, content, 'overlay')
 }
 
-/** Resolve relative plugin paths in one patch file's `insert` rows without changing assertion names. */
+/** Convert inserted filesystem paths to file URLs, anchoring relative paths beside the patch; keep assertion names literal. */
 function anchorInsertedPluginNames(patches: PatchOptions[], file: string): PatchOptions[] {
   const base = dirname(resolve(file))
   const visit = (entry: EntryOptions): void => {
-    if (typeof entry.name === 'string' && (entry.name.startsWith('./') || entry.name.startsWith('../'))) {
+    if (typeof entry.name === 'string' && (isAbsolute(entry.name) || entry.name.startsWith('./') || entry.name.startsWith('../'))) {
       entry.name = pathToFileURL(resolve(base, entry.name)).href
     }
     if (entry.group && Array.isArray(entry.config)) entry.config.forEach(visit)

+ 38 - 0
packages/boot/app-boot/tests/user-patches.spec.ts

@@ -17,6 +17,7 @@ import Timer from '@deepseek-ai/cordis-plugin-timer'
 import {
   boot,
   loadOptionalPatches,
+  loadOverlayPatches,
   PROFILE_PATCH_FILENAME,
   watchUserPatches,
 } from '../src/index.ts'
@@ -74,6 +75,43 @@ describe('loadOptionalPatches', () => {
     expect(patches?.[1]?.insert).toHaveLength(1)
   })
 
+  it.each([
+    { label: 'optional', load: loadOptionalPatches },
+    { label: 'overlay', load: loadOverlayPatches },
+  ])('loads absolute plugin paths from patch files as file URLs ($label)', async ({ load }) => {
+    const dir = tmp()
+    const pluginPath = join(dir, 'absolute #100%.mjs')
+    const pluginUrl = pathToFileURL(pluginPath).href
+    writeFileSync(pluginPath, 'export function apply(ctx) { ctx.provide("absolutePatchLoaded", true) }\n')
+    const patchPath = join(dir, PROFILE_PATCH_FILENAME)
+    writeFileSync(patchPath, JSON.stringify([
+      { id: 'existing', name: pluginPath },
+      { insert: [
+        { id: 'absolute', name: pluginPath },
+        { id: 'url', name: pluginUrl },
+        { id: 'bare', name: '@deepseek-ai/dsh-system-prompt' },
+        { id: 'nested', name: 'cordis:group', group: true, config: [
+          { id: 'child', name: pluginPath },
+        ] },
+      ] },
+    ]))
+    const patches = load(NAME, patchPath)!
+    expect(patches[0]?.name).toBe(pluginPath)
+    expect(patches[1]?.insert?.map(entry => entry.name)).toEqual([
+      pluginUrl, pluginUrl, '@deepseek-ai/dsh-system-prompt', 'cordis:group',
+    ])
+    expect((patches[1]?.insert?.[3]?.config as { name: string }[])[0]?.name).toBe(pluginUrl)
+
+    const configPath = join(dir, 'cordis.yml')
+    writeFileSync(configPath, '[]\n')
+    const ctx = await boot(NAME, configPath, [{ insert: [patches[1]!.insert![0]!] }])
+    try {
+      expect(ctx.get('absolutePatchLoaded')).toBe(true)
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
   it('anchors inserted relative plugins to the patch file and keeps assertion names literal', () => {
     const dir = tmp()
     const patchPath = join(dir, PROFILE_PATCH_FILENAME)