format.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. /**
  2. * Trusted repository-package preparation and prepared-manifest validation.
  3. * @module
  4. */
  5. import { cp, copyFile, mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'
  6. import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
  7. import { z } from 'zod'
  8. import { parseMcpDocument } from './mcp.ts'
  9. /** Fixed module filename loaded from an installed prepared plugin package. */
  10. export const PREPARED_ENTRY_FILENAME = 'dsh-plugin.mjs'
  11. /** Fixed directory containing copied static plugin assets. */
  12. export const PREPARED_ASSET_DIRECTORY = 'dsh-plugin-assets'
  13. /** Loader builtin used by every generated repository wrapper. */
  14. export const REPOSITORY_PLUGIN_BUILTIN = 'dsh-repository-plugin'
  15. /** Dependency-provided command that repository package `prepack` lifecycles must invoke. */
  16. export const REPOSITORY_PLUGIN_PREPARE_COMMAND = 'dsh-plugin-prepare'
  17. /** Published package whose direct development dependency supplies the prepare command. */
  18. export const REPOSITORY_PLUGIN_PACKAGE_NAME = '@deepseek-ai/dsh-repository-plugin'
  19. /**
  20. * Whether a package lifecycle declaration names the preparation dependency's helper.
  21. * @param script - package-authored lifecycle command.
  22. * @returns true when the required helper command is present.
  23. */
  24. export function hasRepositoryPrepareCommand(script: string): boolean {
  25. return script.includes(REPOSITORY_PLUGIN_PREPARE_COMMAND)
  26. }
  27. const prepackSchema = z.string().min(1).refine(
  28. hasRepositoryPrepareCommand,
  29. { message: `must invoke ${REPOSITORY_PLUGIN_PREPARE_COMMAND}` },
  30. )
  31. const sourceMetadataSchema = z.object({
  32. skills: z.array(z.string().min(1)).default([]),
  33. mcpServers: z.string().min(1).optional(),
  34. entry: z.string().min(1).optional(),
  35. }).strict().refine(value => value.skills.length > 0 || value.mcpServers !== undefined || value.entry !== undefined, {
  36. message: 'declare at least one skill root, mcpServers file, or compiled entry',
  37. })
  38. const sourcePackageSchema = z.looseObject({
  39. name: z.string().min(1),
  40. devDependencies: z.looseObject({
  41. [REPOSITORY_PLUGIN_PACKAGE_NAME]: z.string().min(1),
  42. }),
  43. scripts: z.looseObject({
  44. prepack: prepackSchema,
  45. }),
  46. dsh: sourceMetadataSchema,
  47. })
  48. const preparedManifestSchema = z.object({
  49. name: z.string().min(1),
  50. skills: z.array(z.string().min(1)),
  51. mcpServers: z.string().min(1).optional(),
  52. entry: z.string().min(1).optional(),
  53. }).strict()
  54. const preparedConfigSchema = z.object({
  55. // Wrappers pass import.meta.url, which is always file: for an installed
  56. // package; any other scheme would only fail later inside fileURLToPath with
  57. // an uncontextualized TypeError, so reject it at this validation boundary.
  58. baseUrl: z.url({ protocol: /^file$/ }),
  59. manifest: preparedManifestSchema,
  60. }).strict()
  61. /** Prepared manifest embedded in the generated wrapper. */
  62. export interface PreparedPluginManifest {
  63. name: string
  64. skills: string[]
  65. mcpServers?: string
  66. entry?: string
  67. }
  68. /** Untrusted generated-wrapper config accepted by the DSH-owned runtime builtin. */
  69. export interface PreparedPluginConfig {
  70. baseUrl: string
  71. manifest: PreparedPluginManifest
  72. }
  73. function formatZodError(label: string, error: z.ZodError): Error {
  74. return new Error(`${label}:\n${z.prettifyError(error)}`)
  75. }
  76. /**
  77. * Validate the config passed by an installed prepared wrapper.
  78. * @param value - wrapper-provided value crossing the file/module boundary.
  79. * @returns a detached typed config.
  80. */
  81. export function parsePreparedPluginConfig(value: unknown): PreparedPluginConfig {
  82. const result = preparedConfigSchema.safeParse(value)
  83. if (!result.success) throw formatZodError('invalid prepared DSH plugin', result.error)
  84. return {
  85. baseUrl: result.data.baseUrl,
  86. manifest: {
  87. name: result.data.manifest.name,
  88. skills: result.data.manifest.skills,
  89. ...result.data.manifest.mcpServers === undefined ? {} : { mcpServers: result.data.manifest.mcpServers },
  90. ...result.data.manifest.entry === undefined ? {} : { entry: result.data.manifest.entry },
  91. },
  92. }
  93. }
  94. /**
  95. * Whether `candidate` resolves outside `root` — the containment check shared
  96. * by prepare-time asset copying and runtime prepared-path resolution.
  97. * @param root - directory that must contain the candidate.
  98. * @param candidate - absolute path to test.
  99. * @returns true when the candidate escapes the root.
  100. */
  101. export function isOutside(root: string, candidate: string): boolean {
  102. const path = relative(root, candidate)
  103. /* v8 ignore next -- Different-drive Windows relative paths cannot be produced on POSIX coverage hosts. */
  104. return path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path)
  105. }
  106. async function sourcePath(pluginDirectory: string, sourceRoot: string, configured: string, kind: 'directory' | 'file'): Promise<string> {
  107. if (isAbsolute(configured)) throw new Error(`DSH plugin asset path must be relative: ${JSON.stringify(configured)}`)
  108. let path: string
  109. try {
  110. path = await realpath(resolve(pluginDirectory, configured))
  111. } catch (cause) {
  112. throw new Error(`DSH plugin asset does not exist: ${JSON.stringify(configured)}`, { cause })
  113. }
  114. if (isOutside(sourceRoot, path)) {
  115. throw new Error(`DSH plugin asset escapes its plugin source root: ${JSON.stringify(configured)}`)
  116. }
  117. const info = await stat(path)
  118. if (kind === 'directory' ? !info.isDirectory() : !info.isFile()) {
  119. throw new Error(`DSH plugin asset is not a ${kind}: ${JSON.stringify(configured)}`)
  120. }
  121. return path
  122. }
  123. function wrapperSource(manifest: PreparedPluginManifest): string {
  124. // The manifest is static, so the wrapper's service dependencies are too:
  125. // declaring them gates the wrapper fiber until the composition provides
  126. // them, which means the runtime's SkillLocal/McpClient children activate
  127. // within the wrapper's own load epoch and their failures (duplicate
  128. // provider names, damaged packages) reject the wrapper's Loader
  129. // transaction instead of leaving a silently PENDING or FAILED child.
  130. const inject = [
  131. 'loader',
  132. ...manifest.skills.length > 0 ? ['skills'] : [],
  133. ...manifest.mcpServers === undefined ? [] : ['tools'],
  134. ]
  135. const entryHelpers = manifest.entry === undefined ? [] : [
  136. 'function unwrap(exports) {',
  137. ' const value = exports?.default ?? exports',
  138. ' return value?.__esModule ? (value.default ?? value) : value',
  139. '}',
  140. ]
  141. const entryApply = manifest.entry === undefined ? [] : [
  142. ' const repositoryPlugin = unwrap(await import(manifest.entry))',
  143. " await mount(ctx, repositoryPlugin, 'repository Plugin entry')",
  144. ]
  145. return [
  146. '// Generated by dsh-plugin-prepare. Do not edit.',
  147. `const manifest = ${JSON.stringify(manifest)}`,
  148. '// Value mirror: Cordis const enum FiberState.ACTIVE; keep aligned with dsh-repository-plugin source.ts.',
  149. 'const FIBER_ACTIVE = 2',
  150. `export const name = ${JSON.stringify(manifest.name)}`,
  151. `export const inject = ${JSON.stringify(inject)}`,
  152. ...entryHelpers,
  153. 'async function mount(ctx, plugin, label, config) {',
  154. ' const fiber = ctx.plugin(plugin, config)',
  155. ' await fiber',
  156. ' if (fiber.state !== FIBER_ACTIVE) {',
  157. ' const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)',
  158. " throw new Error(`${label} did not activate (waiting for services: ${missing.join(', ') || 'unknown'})`)",
  159. ' }',
  160. '}',
  161. 'export async function apply(ctx) {',
  162. ` const runtime = ctx.loader.builtins[${JSON.stringify(REPOSITORY_PLUGIN_BUILTIN)}]`,
  163. ` if (runtime === undefined) throw new Error(${JSON.stringify(`missing Cordis builtin ${REPOSITORY_PLUGIN_BUILTIN}`)})`,
  164. " await mount(ctx, runtime, 'repository Plugin runtime', { baseUrl: import.meta.url, manifest })",
  165. ...entryApply,
  166. '}',
  167. '',
  168. ].join('\n')
  169. }
  170. /**
  171. * Validate and package one `.dsh-plugin` directory into copied assets plus a generated wrapper.
  172. * Outputs are staged and committed by rename, but the final publish (remove
  173. * old outputs, rename assets, rename entry) is not one atomic step: a crash
  174. * mid-publish can leave assets without an entry or neither. Rerunning prepare
  175. * repairs the package; partial outputs are never importable as a plugin.
  176. * @param directory - `.dsh-plugin` package directory; defaults to the prepare process cwd.
  177. * @returns the generated prepared manifest.
  178. */
  179. export async function prepareDshPlugin(directory: string = process.cwd()): Promise<PreparedPluginManifest> {
  180. const pluginDirectory = await realpath(resolve(directory))
  181. let packageValue: unknown
  182. try {
  183. packageValue = JSON.parse(await readFile(join(pluginDirectory, 'package.json'), 'utf8')) as unknown
  184. } catch (cause) {
  185. throw new Error(`failed to read DSH plugin package metadata in ${pluginDirectory}`, { cause })
  186. }
  187. const parsed = sourcePackageSchema.safeParse(packageValue)
  188. if (!parsed.success) throw formatZodError('invalid DSH plugin package.json', parsed.error)
  189. const sourceRoot = await realpath(dirname(pluginDirectory))
  190. const skillSources: string[] = []
  191. for (const configured of parsed.data.dsh.skills) {
  192. const source = await sourcePath(pluginDirectory, sourceRoot, configured, 'directory')
  193. if (!isOutside(source, pluginDirectory)) {
  194. throw new Error(`DSH skill root cannot contain the .dsh-plugin package: ${JSON.stringify(configured)}`)
  195. }
  196. skillSources.push(source)
  197. }
  198. let mcpSource: string | undefined
  199. if (parsed.data.dsh.mcpServers !== undefined) {
  200. mcpSource = await sourcePath(pluginDirectory, sourceRoot, parsed.data.dsh.mcpServers, 'file')
  201. parseMcpDocument(await readFile(mcpSource, 'utf8'))
  202. }
  203. let entry: string | undefined
  204. if (parsed.data.dsh.entry !== undefined) {
  205. const entrySource = await sourcePath(pluginDirectory, pluginDirectory, parsed.data.dsh.entry, 'file')
  206. entry = `./${relative(pluginDirectory, entrySource).split(sep).join('/')}`
  207. }
  208. const manifest: PreparedPluginManifest = {
  209. name: parsed.data.name,
  210. skills: skillSources.map((_, index) => `${PREPARED_ASSET_DIRECTORY}/skills/${index}`),
  211. ...mcpSource === undefined ? {} : { mcpServers: `${PREPARED_ASSET_DIRECTORY}/.mcp.json` },
  212. ...entry === undefined ? {} : { entry },
  213. }
  214. const staging = await mkdtemp(join(pluginDirectory, '.dsh-plugin-prepare-'))
  215. try {
  216. const stagedAssets = join(staging, PREPARED_ASSET_DIRECTORY)
  217. await mkdir(join(stagedAssets, 'skills'), { recursive: true })
  218. await Promise.all(skillSources.map((source, index) => cp(source, join(stagedAssets, 'skills', String(index)), {
  219. recursive: true,
  220. force: false,
  221. errorOnExist: true,
  222. })))
  223. if (mcpSource !== undefined) await copyFile(mcpSource, join(stagedAssets, '.mcp.json'))
  224. await writeFile(join(staging, PREPARED_ENTRY_FILENAME), wrapperSource(manifest))
  225. await rm(join(pluginDirectory, PREPARED_ASSET_DIRECTORY), { recursive: true, force: true })
  226. await rm(join(pluginDirectory, PREPARED_ENTRY_FILENAME), { force: true })
  227. await rename(stagedAssets, join(pluginDirectory, PREPARED_ASSET_DIRECTORY))
  228. await rename(join(staging, PREPARED_ENTRY_FILENAME), join(pluginDirectory, PREPARED_ENTRY_FILENAME))
  229. } finally {
  230. await rm(staging, { recursive: true, force: true })
  231. }
  232. return manifest
  233. }