From 15e444e0d7c06a6540418e8eead0006c11ea7443 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:14:43 +0800 Subject: [PATCH 1/3] fix(boot): normalize absolute plugin paths in patches --- apps/cli/tests/built-bin.e2e.ts | 23 +++++++++-- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 2 + packages/boot/app-boot/README.zh.md | 2 + packages/boot/app-boot/src/index.ts | 4 +- .../boot/app-boot/tests/user-patches.spec.ts | 38 +++++++++++++++++++ 6 files changed, 65 insertions(+), 8 deletions(-) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 8ec24b74d1..fd9417048e 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/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 diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index aa898932cf..b88ffd1dac 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/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: 3f0f5fe369eb8965dbd9d606f5eaf4065e4e450a +README.zh.md: 35a507d57aec1a189128a8be9666ebec74976e4c diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index ddbcc900fc..3f0f5fe369 100644 --- a/packages/boot/app-boot/README.md +++ b/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, including nested group entries; existing-entry name assertions 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. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 184e1a086d..35a507d57a 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/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 加载会把绝对路径以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL,嵌套分组条目也适用;对已有条目名称的断言保持原样。 + ### 预览生效配置 启动前,你可以打印应用将挂载的确切配置:dump 会以 `!!js` 表达式原样展示组合后的条目列表,并按注释分组标明每个源文件及其 patch 层,输出是一份可加载的 YAML 文档。未匹配到任何行的 patch 会连同其层标签一起报告;配置缺失、无法解析或字段无效都会使 dump 失败。 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 8059b4849c..610ec836a2 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/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) diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index a4f6debf97..fe05c4ad6d 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/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) From c70b30a6fb036957b13a9a4e991567f8698847d0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:25:20 +0800 Subject: [PATCH 2/3] test(cli): exercise patch removal without rapid rewrite --- apps/cli/tests/built-bin.e2e.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index fd9417048e..98fd96fddf 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -727,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. @@ -753,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) From 4826e0c2a37dd02a105df68538b51c03e327be62 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:32:47 +0800 Subject: [PATCH 3/3] docs(boot): clarify patch path conversion ownership --- ...026-09-05-patch-plugin-file-urls.i18n.yaml | 6 +++++ .../2026-09-05-patch-plugin-file-urls.md | 27 +++++++++++++++++++ .../2026-09-05-patch-plugin-file-urls.zh.md | 27 +++++++++++++++++++ packages/boot/app-boot/README.i18n.yaml | 4 +-- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- 6 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-05-patch-plugin-file-urls.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-09-05-patch-plugin-file-urls.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-05-patch-plugin-file-urls.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-09-05-patch-plugin-file-urls.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-05-patch-plugin-file-urls.i18n.yaml new file mode 100644 index 0000000000..27ac99b5da --- /dev/null +++ b/.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 diff --git a/.agents/notes/implemented/bug-fix/2026-09-05-patch-plugin-file-urls.md b/.agents/notes/implemented/bug-fix/2026-09-05-patch-plugin-file-urls.md new file mode 100644 index 0000000000..48112483b4 --- /dev/null +++ b/.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. diff --git a/.agents/notes/implemented/bug-fix/2026-09-05-patch-plugin-file-urls.zh.md b/.agents/notes/implemented/bug-fix/2026-09-05-patch-plugin-file-urls.zh.md new file mode 100644 index 0000000000..775a576324 --- /dev/null +++ b/.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 插件,并验证其文件系统标记、初始化与关闭。 diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index b88ffd1dac..cf49f7515d 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/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: 3f0f5fe369eb8965dbd9d606f5eaf4065e4e450a -README.zh.md: 35a507d57aec1a189128a8be9666ebec74976e4c +README.md: 72b10524b6632f90d3bf7852b0504718518a5c33 +README.zh.md: bbbae612a6fdc9c1060902691f4349ca0b9ae806 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 3f0f5fe369..72b10524b6 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -54,7 +54,7 @@ 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, including nested group entries; existing-entry name assertions remain literal. +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 diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 35a507d57a..bbbae612a6 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -54,7 +54,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head 带 `patchReload: live` 的 profile 会监视两份用户 patch 文件:有效编辑无需重启即可重新组合,被拒绝的编辑则让最后一个可用应用继续运行。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR 回退。 -插入条目的插件名可以是绝对文件系统路径、文件 URL 或包标识符。patch 加载会把绝对路径以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL,嵌套分组条目也适用;对已有条目名称的断言保持原样。 +插入条目的插件名可以是绝对文件系统路径、文件 URL 或包标识符。patch 加载会把 `insert` 条目及其嵌套分组中的绝对路径以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL;对已有条目名称的断言及替换用的 `config` 值保持原样。 ### 预览生效配置