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

@@ -31,7 +31,10 @@ const preparedManifestSchema = z.object({
mcpServers: z.string().min(1).optional(),
}).strict()
const preparedConfigSchema = z.object({
baseUrl: z.url(),
// Wrappers pass import.meta.url, which is always file: for an installed
// package; any other scheme would only fail later inside fileURLToPath with
// an uncontextualized TypeError, so reject it at this validation boundary.
baseUrl: z.url({ protocol: /^file$/ }),
manifest: preparedManifestSchema,
}).strict()
@@ -70,7 +73,14 @@ export function parsePreparedPluginConfig(value: unknown): PreparedPluginConfig
}
}
function isOutside(root: string, candidate: string): boolean {
/**
* Whether `candidate` resolves outside `root` — the containment check shared
* by prepare-time asset copying and runtime prepared-path resolution.
* @param root - directory that must contain the candidate.
* @param candidate - absolute path to test.
* @returns true when the candidate escapes the root.
*/
export function isOutside(root: string, candidate: string): boolean {
const path = relative(root, candidate)
/* v8 ignore next -- Different-drive Windows relative paths cannot be produced on POSIX coverage hosts. */
return path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path)
@@ -95,11 +105,22 @@ async function sourcePath(pluginDirectory: string, sourceRoot: string, configure
}
function wrapperSource(manifest: PreparedPluginManifest): string {
// The manifest is static, so the wrapper's service dependencies are too:
// declaring them gates the wrapper fiber until the composition provides
// them, which means the runtime's SkillLocal/McpClient children activate
// within the wrapper's own load epoch and their failures (duplicate
// provider names, damaged packages) reject the wrapper's Loader
// transaction instead of leaving a silently PENDING or FAILED child.
const inject = [
'loader',
...manifest.skills.length > 0 ? ['skills'] : [],
...manifest.mcpServers === undefined ? [] : ['tools'],
]
return [
'// Generated by dsh-plugin-prepare. Do not edit.',
`const manifest = ${JSON.stringify(manifest)}`,
`export const name = ${JSON.stringify(manifest.name)}`,
"export const inject = ['loader']",
`export const inject = ${JSON.stringify(inject)}`,
'export async function apply(ctx) {',
` const runtime = ctx.loader.builtins[${JSON.stringify(REPOSITORY_PLUGIN_BUILTIN)}]`,
` if (runtime === undefined) throw new Error(${JSON.stringify(`missing Cordis builtin ${REPOSITORY_PLUGIN_BUILTIN}`)})`,
@@ -111,6 +132,10 @@ function wrapperSource(manifest: PreparedPluginManifest): string {
/**
* Validate and package one `.dsh-plugin` directory into static assets plus a fixed wrapper.
* Outputs are staged and committed by rename, but the final publish (remove
* old outputs, rename assets, rename entry) is not one atomic step: a crash
* mid-publish can leave assets without an entry or neither. Rerunning prepare
* repairs the package; partial outputs are never importable as a plugin.
* @param directory - `.dsh-plugin` package directory; defaults to the prepare process cwd.
* @returns the generated static manifest.
*/

View File

@@ -3,8 +3,8 @@
* @module @deepseek-ai/dsh-repository-plugin
*/
import { readFile } from 'node:fs/promises'
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
import { readFile, stat } from 'node:fs/promises'
import { dirname, isAbsolute, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Context } from 'cordis'
import type {} from '@cordisjs/plugin-loader'
@@ -12,6 +12,7 @@ import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
import * as McpClient from '@deepseek-ai/dsh-mcp-client'
import {
REPOSITORY_PLUGIN_BUILTIN,
isOutside,
parsePreparedPluginConfig,
type PreparedPluginConfig,
} from './format.ts'
@@ -34,24 +35,42 @@ function preparedPath(baseUrl: string, configured: string): string {
if (isAbsolute(configured)) throw new Error(`prepared DSH plugin path must be relative: ${JSON.stringify(configured)}`)
const directory = dirname(fileURLToPath(baseUrl))
const path = resolve(directory, configured)
const rel = relative(directory, path)
/* v8 ignore next -- Different-drive Windows relative paths cannot be produced on POSIX coverage hosts. */
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
if (isOutside(directory, path)) {
throw new Error(`prepared DSH plugin path escapes its package: ${JSON.stringify(configured)}`)
}
return path
}
async function preparedDirectory(baseUrl: string, configured: string): Promise<string> {
const path = preparedPath(baseUrl, configured)
// A manifest-declared skill root missing from the installed package (files/
// .npmignore dropping generated outputs, a damaged cache entry) must fail
// the plugin load: the skill provider treats an absent root as legitimately
// empty, which would silently mount a skill-less plugin.
let info
try {
info = await stat(path)
} catch (cause) {
throw new Error(`prepared DSH plugin skill root is missing from the installed package: ${JSON.stringify(configured)}`, { cause })
}
if (!info.isDirectory()) {
throw new Error(`prepared DSH plugin skill root is not a directory: ${JSON.stringify(configured)}`)
}
return path
}
async function applyPrepared(ctx: Context, value: PreparedPluginConfig): Promise<void> {
const config = parsePreparedPluginConfig(value)
const directory = dirname(fileURLToPath(config.baseUrl))
const skillDirectories = config.manifest.skills.map(path => preparedPath(config.baseUrl, path))
const skillDirectories = await Promise.all(config.manifest.skills.map(path => preparedDirectory(config.baseUrl, path)))
const mcpConfigs = config.manifest.mcpServers === undefined
? []
: resolveMcpServers(
parseMcpDocument(await readFile(preparedPath(config.baseUrl, config.manifest.mcpServers), 'utf8')),
process.env,
directory,
// Schemastery call signatures collapse the parameter to `never` under
// NodeNext; ResolvedMcpServer is shaped for the Config union by design.
).map(input => McpClient.Config(input as never))
await ctx.effect(async function* () {

View File

@@ -5,7 +5,14 @@
import { z } from 'zod'
const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/
/**
* Restates dsh-mcp-client's `SERVER_NAME_PATTERN` rather than importing it:
* the prepare bin must stay a zod-only module graph (no tools seam, no MCP
* SDK). Exported so `repository-plugin.spec.ts` pins equality with the
* client's exported pattern — prepare-time validation cannot drift from the
* registry that enforces uniqueness.
*/
export const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/
const ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
const PLACEHOLDER_PATTERN = /\$\{([^}]*)\}/g
@@ -89,7 +96,7 @@ export function parseMcpDocument(content: string): McpDocument {
if (!result.success) throw new Error(`invalid .mcp.json:\n${z.prettifyError(result.error)}`)
for (const [serverName, definition] of Object.entries(result.data.mcpServers)) {
if (!SERVER_NAME_PATTERN.test(serverName)) {
throw new Error(`invalid .mcp.json: server name ${JSON.stringify(serverName)} must match [A-Za-z0-9_-]{1,32}`)
throw new Error(`invalid .mcp.json: server name ${JSON.stringify(serverName)} must match ${SERVER_NAME_PATTERN.source}`)
}
visitStrings(serverName, definition, assertTemplate)
}