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] 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)