fix(review): validate skill roots at mount and isolate provider default roots

ds-review-bot round 1 on the repository-plugin runtime:
- a manifest-declared skill root absent or non-directory in the installed
  package now fails the plugin load (skill-local treats a missing root as
  legitimately empty, which silently mounted a skill-less plugin)
- includeDefaultRoots: false no longer inherits $DSH_BUNDLED_SKILL_DIR, so
  isolated repository providers see only their explicit roots
- prepared wrapper baseUrl schema requires the file: scheme, failing hostile
  URLs at the declared validation boundary
- preparedPath reuses format.ts's isOutside; SERVER_NAME_PATTERN is exported
  and pinned equal to dsh-mcp-client's, with the restatement justified (the
  prepare bin keeps a zod-only module graph); the unexplained `as never`
  cast now carries its schemastery rationale
- the import-free wrapper assertion also rejects dynamic import(
- the headless fixture wrapper is regenerated by the real prepareDshPlugin
  and a drift test pins fixture == generator output
- prepareDshPlugin JSDoc states the non-atomic publish repair contract
This commit is contained in:
Tianyi Cui
2026-08-01 21:16:23 +08:00
parent a0aed8a19f
commit 0664b25cd9
20 changed files with 175 additions and 36 deletions

View File

@@ -1,7 +1,15 @@
import { describe, expect, it } from 'vitest'
import { parseMcpDocument, resolveMcpServers } from '../src/mcp.ts'
import { SERVER_NAME_PATTERN as CLIENT_SERVER_NAME_PATTERN } from '@deepseek-ai/dsh-mcp-client'
import { SERVER_NAME_PATTERN, parseMcpDocument, resolveMcpServers } from '../src/mcp.ts'
describe('repository plugin common .mcp.json support', () => {
it('validates server names with exactly the pattern the MCP client registry enforces', () => {
// mcp.ts restates the pattern to keep the prepare bin's module graph
// zod-only; this pin is the drift guard.
expect(SERVER_NAME_PATTERN.source).toBe(CLIENT_SERVER_NAME_PATTERN.source)
expect(SERVER_NAME_PATTERN.flags).toBe(CLIENT_SERVER_NAME_PATTERN.flags)
})
it('maps Expo-style HTTP servers to the existing Streamable HTTP client config', () => {
const document = parseMcpDocument(JSON.stringify({
mcpServers: {

View File

@@ -59,7 +59,9 @@ describe('dsh-plugin-prepare', () => {
})
const wrapper = await readFile(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME), 'utf8')
expect(wrapper).toContain(`ctx.loader.builtins["${RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN}"]`)
expect(wrapper).not.toMatch(/\b(?:import|from)\s/)
// Import-free means no static AND no dynamic imports; `import.meta.url`
// (no whitespace, no call parenthesis) is the one allowed appearance.
expect(wrapper).not.toMatch(/\b(?:import|from)\s|\bimport\s*\(/)
await expect(readFile(join(directory, 'dsh-plugin-assets/skills/0/repository-fixture/SKILL.md'), 'utf8'))
.resolves.toContain('Static instructions.')
await expect(readFile(join(directory, 'dsh-plugin-assets/.mcp.json'), 'utf8'))
@@ -218,6 +220,34 @@ describe('prepared repository plugin Loader composition', () => {
await ctx.fiber.dispose()
})
it('fails the plugin load when a declared skill root is missing or not a directory', async () => {
const root = await temporaryDirectory('missing-skill-root')
await writeFile(join(root, 'not-a-directory'), 'text')
const ctx = new Context()
ctx.baseUrl = pathToFileURL(root).href + '/'
await ctx.plugin(Loader)
await ctx.plugin(SkillService)
await ctx.plugin(RepositoryPlugin)
for (const [filename, skillPath, message] of [
['missing.mjs', 'dsh-plugin-assets/skills/0', 'skill root is missing from the installed package'],
['file.mjs', 'not-a-directory', 'skill root is not a directory'],
] as const) {
const wrapper = join(root, filename)
await writeFile(wrapper, [
"export const inject = ['loader']",
'export async function apply(ctx) {',
` await ctx.plugin(ctx.loader.builtins['${RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN}'], {`,
` baseUrl: import.meta.url, manifest: { name: 'damaged', skills: [${JSON.stringify(skillPath)}] },`,
' })',
'}',
'',
].join('\n'))
await expect(ctx.loader.create({ name: pathToFileURL(wrapper).href })).rejects.toThrow(message)
}
await ctx.fiber.dispose()
})
it('rejects duplicate builtin ownership and preserves a later replacement on teardown', async () => {
const ctx = new Context()
await ctx.plugin(Loader)