feat: add static repository plugin format
This commit is contained in:
6
packages/cordis/repository-plugin/README.i18n.yaml
Normal file
6
packages/cordis/repository-plugin/README.i18n.yaml
Normal file
@@ -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 packages/cordis/repository-plugin/README.md
|
||||
README.md: dab6287304f083e5c0ae128d5a3cb861332c076a
|
||||
README.zh.md: 790e601ad022ffa20c7d02a88353e972bf8bffe2
|
||||
85
packages/cordis/repository-plugin/README.md
Normal file
85
packages/cordis/repository-plugin/README.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# @deepseek-ai/dsh-repository-plugin
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Restricted repository Plugin format for DeepSeek Harness. A repository author declares static skill roots and an optional common `.mcp.json` in `.dsh-plugin/package.json`; the prepare helper copies those assets and emits a fixed import-free Cordis wrapper. The runtime wrapper can only delegate to this DSH-owned package, which composes [`dsh-skill-local`](../../skill/skill-local/README.md) and [`dsh-mcp-client`](../../mcp/mcp-client/README.md). Design rationale: [static repository Plugin format Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md).
|
||||
|
||||
## Authoring format
|
||||
|
||||
Place an ordinary package in the repository's `.dsh-plugin` directory:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "humanize-dsh-plugin",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"prepare": "dsh-plugin-prepare"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-repository-plugin": "^0.0.1"
|
||||
},
|
||||
"dsh": {
|
||||
"skills": ["../skills"],
|
||||
"mcpServers": "../.mcp.json"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`dsh.skills` is an optional array of local skill roots. `dsh.mcpServers` is an optional path to one `.mcp.json`; at least one field is required. Paths are relative to `.dsh-plugin`, must stay under its parent source directory, and may therefore refer to existing repository assets such as `../skills`. A repository containing several Plugins gives each one its own `.dsh-plugin` package under a different selectable subdirectory.
|
||||
|
||||
## Preparation
|
||||
|
||||
`dsh-plugin-prepare` validates `package.json#dsh`, verifies skill-root types, parses the MCP file, copies assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. The wrapper contains only the normalized static manifest and fixed code that looks up the `dsh-repository-plugin` Loader builtin. It neither discovers nor compiles repository JavaScript, and the runtime never imports another repository entry point.
|
||||
|
||||
The containing package manager still runs the configured repository package's lifecycle scripts. This restriction defines the supported DSH contribution surface; it is not a security boundary for a repository that the user chose to install as executable package-manager source.
|
||||
|
||||
## Runtime composition
|
||||
|
||||
Loading this package registers one effect-scoped Loader builtin. Each generated wrapper delegates to that builtin with its own module URL and prepared manifest. Repository skill roots mount as a uniquely named `dsh-skill-local` provider with default project/user roots excluded and watching disabled; cached package generations are immutable. Wrapper disposal removes the provider and all composed MCP clients through normal Cordis child-fiber teardown.
|
||||
|
||||
## Common MCP format
|
||||
|
||||
The `.mcp.json` root is `{ "mcpServers": { ... } }`. A stdio entry accepts only `type: "stdio"` (optional), `command`, `args`, and `env`; an HTTP entry accepts only `type: "http"`, `url`, and `headers`. String values support exact `${NAME}` process-environment expansion at Plugin load, and a missing name fails that load. HTTP URLs become the existing MCP client's `streamable-http` transport; stdio entries use the prepared package directory as `cwd`.
|
||||
|
||||
Unknown fields reject, including OAuth and `auth` objects. There is no `CLAUDE_PLUGIN_ROOT` expansion or compatibility layer. After translation, the existing `dsh-mcp-client` exclusively owns transport creation, connection diagnostics, tool synchronization, calls, and disconnect lifecycle; a network or child-process connection failure retains that client's established log-and-no-tools behavior.
|
||||
|
||||
## Export shape
|
||||
|
||||
Namespace Plugin: named exports `name` / `inject` / `apply`, preparation constants, and `prepareDshPlugin`; no default export. The package also exposes the `dsh-plugin-prepare` executable and an invariant companion.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Repository skills
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Indirectly through `dsh-tool-skill`: prepared, model-invocable skills join its logged catalog and selected instruction-body surface under their declared names and descriptions. The exact consumer schema is in the generated [`skill` tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-tool-skill).
|
||||
|
||||
#### Token effect
|
||||
|
||||
Conditional and data-dependent: each visible repository skill adds one capped catalog row; loading one adds its full current instruction body and resource-base guidance to retained tool history.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
A stable prepared Plugin set is prefix-stable. Adding, removing, or replacing a repository Plugin can append the consumer's replacement catalog and affect later request prefixes.
|
||||
|
||||
### Repository MCP tools
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Indirectly through `dsh-mcp-client`: every connected server contributes its server-qualified tool schemas, and calls retain that client's canonical MCP results and rendering.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Conditional on successful connection and the remote tool list; schemas recur on requests in the active tool view, while calls and results remain in history until compaction.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Stable connected tool lists are prefix-stable. Plugin lifecycle or MCP tool-list changes can change later tool-schema prefixes from the first affected definition.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Skills and MCP only** — commands, hooks, agents, apps, arbitrary Cordis code, marketplaces, and compatibility shims are intentionally outside this format.
|
||||
- **No MCP authentication protocol** — static headers may use environment expansion, but OAuth-bearing definitions reject and private-server login flows are not implemented here.
|
||||
- **Generated assets are immutable runtime input** — repository cache generations are not watched; source, ref, path, or configuration must select another prepared generation.
|
||||
85
packages/cordis/repository-plugin/README.zh.md
Normal file
85
packages/cordis/repository-plugin/README.zh.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# @deepseek-ai/dsh-repository-plugin
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
这是 DeepSeek Harness 的受限 repository Plugin 格式。仓库作者在 `.dsh-plugin/package.json` 中声明静态 skill 根和可选的通用 `.mcp.json`;prepare helper 会复制这些资源并生成固定、无 import 的 Cordis 包装模块。运行时包装模块只能委托给这个由 DSH 自有的包,再由它组合 [`dsh-skill-local`](../../skill/skill-local/README.md) 与 [`dsh-mcp-client`](../../mcp/mcp-client/README.md)。设计依据见[静态 repository Plugin 格式 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md)。
|
||||
|
||||
## 创作格式
|
||||
|
||||
在仓库的 `.dsh-plugin` 目录中放置一个普通 package:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "humanize-dsh-plugin",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"prepare": "dsh-plugin-prepare"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-repository-plugin": "^0.0.1"
|
||||
},
|
||||
"dsh": {
|
||||
"skills": ["../skills"],
|
||||
"mcpServers": "../.mcp.json"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`dsh.skills` 是可选的本地 skill 根数组。`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径;两者至少声明一个。路径相对于 `.dsh-plugin`,必须留在其父级源码目录下,因此可以引用 `../skills` 等仓库现有资源。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` package。
|
||||
|
||||
## 准备阶段
|
||||
|
||||
`dsh-plugin-prepare` 校验 `package.json#dsh`、确认 skill 根类型、解析 MCP 文件、把资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。包装模块只包含规范化后的静态 manifest(元数据清单),以及查找 `dsh-repository-plugin` Loader builtin 的固定代码;它不会发现或编译仓库 JavaScript,运行时也不会导入仓库的其他入口。
|
||||
|
||||
外层 package manager 仍会运行已配置仓库 package 的生命周期脚本。这里的限制只定义 DSH 所支持的贡献表面;对于用户选择以可执行 package-manager source 安装的仓库,它并不是安全边界。
|
||||
|
||||
## 运行时组合
|
||||
|
||||
加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装模块都把自身模块 URL 和已准备的 manifest 委托给该 builtin。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存 package generation 是不可变的。包装模块 dispose 时,会通过正常的 Cordis 子 fiber teardown 移除提供方和所有组合的 MCP client。
|
||||
|
||||
## 通用 MCP 格式
|
||||
|
||||
`.mcp.json` 根对象是 `{ "mcpServers": { ... } }`。stdio 条目只接受可选的 `type: "stdio"`、`command`、`args` 和 `env`;HTTP 条目只接受 `type: "http"`、`url` 和 `headers`。字符串值在 Plugin 加载时支持严格的 `${NAME}` 进程环境变量展开;缺失变量会使该次加载失败。HTTP URL 映射到现有 MCP client 的 `streamable-http` transport;stdio 条目以已准备的 package 目录作为 `cwd`。
|
||||
|
||||
未知字段会被拒绝,包括 OAuth 字段与 `auth` 对象。不提供 `CLAUDE_PLUGIN_ROOT` 展开或兼容层。完成格式转换后,现有 `dsh-mcp-client` 独占 transport 创建、连接诊断、工具同步、调用和断开生命周期;网络或子进程连接失败沿用该 client 既有的“记录错误且不注册工具”行为。
|
||||
|
||||
## 导出形状
|
||||
|
||||
Namespace Plugin:具名导出 `name`/`inject`/`apply`、准备阶段常量和 `prepareDshPlugin`,不提供 default export。本包还提供 `dsh-plugin-prepare` 可执行文件和 invariant companion。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### Repository skills
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
通过 `dsh-tool-skill` 间接呈现:已准备且允许模型调用的 skill 会按其声明的名称和描述进入该消费方记录到日志的目录及所选指令正文表面。消费方的确切 schema 见生成的 [`skill` 工具目录](../../../docs/tool-catalog.md#deepseek-aidsh-tool-skill)。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
有条件且随数据变化:每个可见的 repository skill 增加一行受限长度的目录项;加载一个 skill 会把其当前完整指令正文和资源基准指引加入保留的工具历史。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
稳定的已准备 Plugin 集合保持前缀稳定。添加、移除或替换 repository Plugin 可能使消费方追加替换目录,并影响后续请求前缀。
|
||||
|
||||
### Repository MCP 工具
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
通过 `dsh-mcp-client` 间接呈现:每个已连接 server 都贡献带 server 限定名的工具 schema;调用会保留该 client 的规范 MCP 结果和渲染。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
取决于连接成功和远端工具列表;schema 会在对应工具视图中的请求上重复出现,而调用与结果会留在历史中直至压缩。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
稳定的已连接工具列表保持前缀稳定。Plugin 生命周期或 MCP 工具列表变化可能从首个受影响定义开始改变后续工具 schema 前缀。
|
||||
|
||||
## 已知限制与延后工作
|
||||
|
||||
- **仅支持 skills 与 MCP**:commands、hooks、agents、apps、任意 Cordis 代码、marketplace 和兼容 shim 均有意排除在该格式之外。
|
||||
- **没有 MCP 认证协议**:静态 header 可以使用环境变量展开,但带 OAuth 的定义会被拒绝,私有 server 登录流程不在此实现。
|
||||
- **生成资源是不可变运行时输入**:repository cache generation 不受监视;必须改变 source、ref、path 或配置才能选择另一份已准备 generation。
|
||||
53
packages/cordis/repository-plugin/package.json
Normal file
53
packages/cordis/repository-plugin/package.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-repository-plugin",
|
||||
"description": "Restricted repository plugin format and Cordis runtime for DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"bin": {
|
||||
"dsh-plugin-prepare": "./lib/bin.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/bin.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-mcp-client": "^0.0.1",
|
||||
"@deepseek-ai/dsh-skill-local": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-mcp-client": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
12
packages/cordis/repository-plugin/src/bin.ts
Normal file
12
packages/cordis/repository-plugin/src/bin.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/** Command-line entry that prepares the current `.dsh-plugin` package. @module */
|
||||
|
||||
import { prepareDshPlugin } from './format.ts'
|
||||
|
||||
try {
|
||||
await prepareDshPlugin()
|
||||
} catch (error) {
|
||||
process.stderr.write(`dsh-plugin-prepare: ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
168
packages/cordis/repository-plugin/src/format.ts
Normal file
168
packages/cordis/repository-plugin/src/format.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Static repository-plugin preparation and prepared-manifest validation.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { cp, copyFile, mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { parseMcpDocument } from './mcp.ts'
|
||||
|
||||
/** Fixed module filename loaded from an installed prepared plugin package. */
|
||||
export const PREPARED_ENTRY_FILENAME = 'dsh-plugin.mjs'
|
||||
/** Fixed directory containing copied static plugin assets. */
|
||||
export const PREPARED_ASSET_DIRECTORY = 'dsh-plugin-assets'
|
||||
/** Loader builtin used by every generated import-free wrapper. */
|
||||
export const REPOSITORY_PLUGIN_BUILTIN = 'dsh-repository-plugin'
|
||||
|
||||
const sourceMetadataSchema = z.object({
|
||||
skills: z.array(z.string().min(1)).default([]),
|
||||
mcpServers: z.string().min(1).optional(),
|
||||
}).strict().refine(value => value.skills.length > 0 || value.mcpServers !== undefined, {
|
||||
message: 'declare at least one skill root or mcpServers file',
|
||||
})
|
||||
const sourcePackageSchema = z.looseObject({
|
||||
name: z.string().min(1),
|
||||
dsh: sourceMetadataSchema,
|
||||
})
|
||||
const preparedManifestSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
skills: z.array(z.string().min(1)),
|
||||
mcpServers: z.string().min(1).optional(),
|
||||
}).strict()
|
||||
const preparedConfigSchema = z.object({
|
||||
baseUrl: z.url(),
|
||||
manifest: preparedManifestSchema,
|
||||
}).strict()
|
||||
|
||||
/** Static manifest embedded in the generated wrapper. */
|
||||
export interface PreparedPluginManifest {
|
||||
name: string
|
||||
skills: string[]
|
||||
mcpServers?: string
|
||||
}
|
||||
|
||||
/** Untrusted generated-wrapper config accepted by the DSH-owned runtime builtin. */
|
||||
export interface PreparedPluginConfig {
|
||||
baseUrl: string
|
||||
manifest: PreparedPluginManifest
|
||||
}
|
||||
|
||||
function formatZodError(label: string, error: z.ZodError): Error {
|
||||
return new Error(`${label}:\n${z.prettifyError(error)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the config passed by an installed prepared wrapper.
|
||||
* @param value - wrapper-provided value crossing the file/module boundary.
|
||||
* @returns a detached typed config.
|
||||
*/
|
||||
export function parsePreparedPluginConfig(value: unknown): PreparedPluginConfig {
|
||||
const result = preparedConfigSchema.safeParse(value)
|
||||
if (!result.success) throw formatZodError('invalid prepared DSH plugin', result.error)
|
||||
return {
|
||||
baseUrl: result.data.baseUrl,
|
||||
manifest: {
|
||||
name: result.data.manifest.name,
|
||||
skills: result.data.manifest.skills,
|
||||
...result.data.manifest.mcpServers === undefined ? {} : { mcpServers: result.data.manifest.mcpServers },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
async function sourcePath(pluginDirectory: string, sourceRoot: string, configured: string, kind: 'directory' | 'file'): Promise<string> {
|
||||
if (isAbsolute(configured)) throw new Error(`DSH plugin asset path must be relative: ${JSON.stringify(configured)}`)
|
||||
let path: string
|
||||
try {
|
||||
path = await realpath(resolve(pluginDirectory, configured))
|
||||
} catch (cause) {
|
||||
throw new Error(`DSH plugin asset does not exist: ${JSON.stringify(configured)}`, { cause })
|
||||
}
|
||||
if (isOutside(sourceRoot, path)) {
|
||||
throw new Error(`DSH plugin asset escapes its plugin source root: ${JSON.stringify(configured)}`)
|
||||
}
|
||||
const info = await stat(path)
|
||||
if (kind === 'directory' ? !info.isDirectory() : !info.isFile()) {
|
||||
throw new Error(`DSH plugin asset is not a ${kind}: ${JSON.stringify(configured)}`)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
function wrapperSource(manifest: PreparedPluginManifest): string {
|
||||
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 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}`)})`,
|
||||
' await ctx.plugin(runtime, { baseUrl: import.meta.url, manifest })',
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and package one `.dsh-plugin` directory into static assets plus a fixed wrapper.
|
||||
* @param directory - `.dsh-plugin` package directory; defaults to the prepare process cwd.
|
||||
* @returns the generated static manifest.
|
||||
*/
|
||||
export async function prepareDshPlugin(directory: string = process.cwd()): Promise<PreparedPluginManifest> {
|
||||
const pluginDirectory = await realpath(resolve(directory))
|
||||
let packageValue: unknown
|
||||
try {
|
||||
packageValue = JSON.parse(await readFile(join(pluginDirectory, 'package.json'), 'utf8')) as unknown
|
||||
} catch (cause) {
|
||||
throw new Error(`failed to read DSH plugin package metadata in ${pluginDirectory}`, { cause })
|
||||
}
|
||||
const parsed = sourcePackageSchema.safeParse(packageValue)
|
||||
if (!parsed.success) throw formatZodError('invalid package.json#dsh', parsed.error)
|
||||
|
||||
const sourceRoot = await realpath(dirname(pluginDirectory))
|
||||
const skillSources: string[] = []
|
||||
for (const configured of parsed.data.dsh.skills) {
|
||||
const source = await sourcePath(pluginDirectory, sourceRoot, configured, 'directory')
|
||||
if (!isOutside(source, pluginDirectory)) {
|
||||
throw new Error(`DSH skill root cannot contain the .dsh-plugin package: ${JSON.stringify(configured)}`)
|
||||
}
|
||||
skillSources.push(source)
|
||||
}
|
||||
let mcpSource: string | undefined
|
||||
if (parsed.data.dsh.mcpServers !== undefined) {
|
||||
mcpSource = await sourcePath(pluginDirectory, sourceRoot, parsed.data.dsh.mcpServers, 'file')
|
||||
parseMcpDocument(await readFile(mcpSource, 'utf8'))
|
||||
}
|
||||
|
||||
const manifest: PreparedPluginManifest = {
|
||||
name: parsed.data.name,
|
||||
skills: skillSources.map((_, index) => `${PREPARED_ASSET_DIRECTORY}/skills/${index}`),
|
||||
...mcpSource === undefined ? {} : { mcpServers: `${PREPARED_ASSET_DIRECTORY}/.mcp.json` },
|
||||
}
|
||||
const staging = await mkdtemp(join(pluginDirectory, '.dsh-plugin-prepare-'))
|
||||
try {
|
||||
const stagedAssets = join(staging, PREPARED_ASSET_DIRECTORY)
|
||||
await mkdir(join(stagedAssets, 'skills'), { recursive: true })
|
||||
await Promise.all(skillSources.map((source, index) => cp(source, join(stagedAssets, 'skills', String(index)), {
|
||||
recursive: true,
|
||||
force: false,
|
||||
errorOnExist: true,
|
||||
})))
|
||||
if (mcpSource !== undefined) await copyFile(mcpSource, join(stagedAssets, '.mcp.json'))
|
||||
await writeFile(join(staging, PREPARED_ENTRY_FILENAME), wrapperSource(manifest))
|
||||
|
||||
await rm(join(pluginDirectory, PREPARED_ASSET_DIRECTORY), { recursive: true, force: true })
|
||||
await rm(join(pluginDirectory, PREPARED_ENTRY_FILENAME), { force: true })
|
||||
await rename(stagedAssets, join(pluginDirectory, PREPARED_ASSET_DIRECTORY))
|
||||
await rename(join(staging, PREPARED_ENTRY_FILENAME), join(pluginDirectory, PREPARED_ENTRY_FILENAME))
|
||||
} finally {
|
||||
await rm(staging, { recursive: true, force: true })
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
97
packages/cordis/repository-plugin/src/index.ts
Normal file
97
packages/cordis/repository-plugin/src/index.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Restricted repository-plugin runtime for static skills and common MCP definitions.
|
||||
* @module @deepseek-ai/dsh-repository-plugin
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from '@cordisjs/plugin-loader'
|
||||
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
||||
import * as McpClient from '@deepseek-ai/dsh-mcp-client'
|
||||
import {
|
||||
REPOSITORY_PLUGIN_BUILTIN,
|
||||
parsePreparedPluginConfig,
|
||||
type PreparedPluginConfig,
|
||||
} from './format.ts'
|
||||
import { parseMcpDocument, resolveMcpServers } from './mcp.ts'
|
||||
|
||||
export {
|
||||
PREPARED_ASSET_DIRECTORY,
|
||||
PREPARED_ENTRY_FILENAME,
|
||||
REPOSITORY_PLUGIN_BUILTIN,
|
||||
prepareDshPlugin,
|
||||
type PreparedPluginManifest,
|
||||
} from './format.ts'
|
||||
|
||||
/** Cordis plugin name used by Loader diagnostics. */
|
||||
export const name = 'repository-plugin'
|
||||
/** Loader service required to register the fixed prepared-wrapper builtin. */
|
||||
export const inject = ['loader']
|
||||
|
||||
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)) {
|
||||
throw new Error(`prepared DSH plugin path escapes its package: ${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 mcpConfigs = config.manifest.mcpServers === undefined
|
||||
? []
|
||||
: resolveMcpServers(
|
||||
parseMcpDocument(await readFile(preparedPath(config.baseUrl, config.manifest.mcpServers), 'utf8')),
|
||||
process.env,
|
||||
directory,
|
||||
).map(input => McpClient.Config(input as never))
|
||||
|
||||
await ctx.effect(async function* () {
|
||||
if (skillDirectories.length > 0) {
|
||||
const skills = ctx.plugin(SkillLocal, {
|
||||
providerName: `repository:${config.manifest.name}`,
|
||||
includeDefaultRoots: false,
|
||||
customSkillDirs: skillDirectories,
|
||||
watch: false,
|
||||
})
|
||||
await skills
|
||||
yield skills.dispose
|
||||
}
|
||||
for (const mcpConfig of mcpConfigs) {
|
||||
const mcp = ctx.plugin(McpClient, mcpConfig)
|
||||
await mcp
|
||||
yield mcp.dispose
|
||||
}
|
||||
}, `repository-plugin(${config.manifest.name})`)
|
||||
}
|
||||
|
||||
const preparedRuntime = {
|
||||
name: 'repository-plugin-runtime',
|
||||
apply: applyPrepared,
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the DSH-owned runtime as the Loader builtin used by fixed prepared wrappers.
|
||||
* @param ctx - plugin context carrying the Loader service.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] !== undefined) {
|
||||
throw new Error(`Loader builtin ${REPOSITORY_PLUGIN_BUILTIN} is already registered`)
|
||||
}
|
||||
ctx.effect(function* () {
|
||||
ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] = preparedRuntime
|
||||
yield () => {
|
||||
if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] === preparedRuntime) {
|
||||
Reflect.deleteProperty(ctx.loader.builtins, REPOSITORY_PLUGIN_BUILTIN)
|
||||
}
|
||||
}
|
||||
}, 'repository-plugin Loader builtin')
|
||||
}
|
||||
30
packages/cordis/repository-plugin/src/invariant.ts
Normal file
30
packages/cordis/repository-plugin/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-repository-plugin`.
|
||||
* @module @deepseek-ai/dsh-repository-plugin/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-repository-plugin'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'repository-plugin-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the package owns no service state; Loader fibers and the existing skill
|
||||
* and MCP owners expose the authoritative lifecycle relationships for its composed children.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
145
packages/cordis/repository-plugin/src/mcp.ts
Normal file
145
packages/cordis/repository-plugin/src/mcp.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Parser for the common `.mcp.json` file consumed by prepared repository plugins.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
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
|
||||
|
||||
const stringMap = z.record(z.string(), z.string())
|
||||
const stdioServerSchema = z.object({
|
||||
type: z.literal('stdio').optional(),
|
||||
command: z.string().min(1),
|
||||
args: z.array(z.string()).optional(),
|
||||
env: stringMap.optional(),
|
||||
}).strict()
|
||||
const httpServerSchema = z.object({
|
||||
type: z.literal('http'),
|
||||
url: z.string().min(1),
|
||||
headers: stringMap.optional(),
|
||||
}).strict()
|
||||
const documentSchema = z.object({
|
||||
mcpServers: z.record(z.string(), z.union([stdioServerSchema, httpServerSchema])),
|
||||
}).strict()
|
||||
|
||||
/** One supported server entry from the common `.mcp.json` format. */
|
||||
export type McpServerDefinition = z.infer<typeof stdioServerSchema> | z.infer<typeof httpServerSchema>
|
||||
|
||||
/** Parsed common MCP document before process-environment expansion. */
|
||||
export interface McpDocument {
|
||||
mcpServers: Record<string, McpServerDefinition>
|
||||
}
|
||||
|
||||
/** Resolved input handed to the existing `dsh-mcp-client` Config schema. */
|
||||
export type ResolvedMcpServer =
|
||||
| {
|
||||
transport: 'stdio'
|
||||
serverName: string
|
||||
command: string
|
||||
args: string[]
|
||||
env: Record<string, string>
|
||||
cwd: string
|
||||
}
|
||||
| {
|
||||
transport: 'streamable-http'
|
||||
serverName: string
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
function assertTemplate(value: string, location: string): void {
|
||||
for (const match of value.matchAll(PLACEHOLDER_PATTERN)) {
|
||||
const name = match[1] as string
|
||||
if (!ENVIRONMENT_NAME_PATTERN.test(name)) {
|
||||
throw new Error(`${location} contains an unsupported environment placeholder ${JSON.stringify(match[0])}`)
|
||||
}
|
||||
}
|
||||
if (value.replace(PLACEHOLDER_PATTERN, '').includes('${')) {
|
||||
throw new Error(`${location} contains an unterminated environment placeholder`)
|
||||
}
|
||||
}
|
||||
|
||||
function visitStrings(serverName: string, definition: McpServerDefinition, visit: (value: string, location: string) => void): void {
|
||||
if ('command' in definition) {
|
||||
visit(definition.command, `mcpServers.${serverName}.command`)
|
||||
definition.args?.forEach((value, index) => { visit(value, `mcpServers.${serverName}.args[${index}]`) })
|
||||
Object.entries(definition.env ?? {}).forEach(([name, value]) => { visit(value, `mcpServers.${serverName}.env.${name}`) })
|
||||
return
|
||||
}
|
||||
visit(definition.url, `mcpServers.${serverName}.url`)
|
||||
Object.entries(definition.headers ?? {}).forEach(([name, value]) => { visit(value, `mcpServers.${serverName}.headers.${name}`) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate one common `.mcp.json` document without resolving environment values.
|
||||
* @param content - UTF-8 JSON document.
|
||||
* @returns the supported stdio and Streamable HTTP server definitions.
|
||||
*/
|
||||
export function parseMcpDocument(content: string): McpDocument {
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(content) as unknown
|
||||
} catch (cause) {
|
||||
throw new Error('invalid .mcp.json: expected JSON', { cause })
|
||||
}
|
||||
const result = documentSchema.safeParse(value)
|
||||
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}`)
|
||||
}
|
||||
visitStrings(serverName, definition, assertTemplate)
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
function expand(value: string, environment: NodeJS.ProcessEnv, location: string): string {
|
||||
return value.replace(PLACEHOLDER_PATTERN, (_placeholder, name: string) => {
|
||||
const replacement = environment[name]
|
||||
if (replacement === undefined) throw new Error(`${location} requires missing environment variable ${name}`)
|
||||
return replacement
|
||||
})
|
||||
}
|
||||
|
||||
function expandMap(values: Record<string, string> | undefined, environment: NodeJS.ProcessEnv, location: string): Record<string, string> {
|
||||
return Object.fromEntries(Object.entries(values ?? {}).map(([name, value]) => [
|
||||
name,
|
||||
expand(value, environment, `${location}.${name}`),
|
||||
]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve supported MCP definitions to inputs for the existing MCP client.
|
||||
* @param document - validated common MCP document.
|
||||
* @param environment - process environment used for exact `${NAME}` expansion.
|
||||
* @param cwd - prepared plugin directory used for stdio child processes.
|
||||
* @returns one existing-client config input per declared server.
|
||||
*/
|
||||
export function resolveMcpServers(document: McpDocument, environment: NodeJS.ProcessEnv, cwd: string): ResolvedMcpServer[] {
|
||||
return Object.entries(document.mcpServers).map(([serverName, definition]) => {
|
||||
if ('command' in definition) {
|
||||
return {
|
||||
transport: 'stdio',
|
||||
serverName,
|
||||
command: expand(definition.command, environment, `mcpServers.${serverName}.command`),
|
||||
args: (definition.args ?? []).map((value, index) => expand(value, environment, `mcpServers.${serverName}.args[${index}]`)),
|
||||
env: expandMap(definition.env, environment, `mcpServers.${serverName}.env`),
|
||||
cwd,
|
||||
}
|
||||
}
|
||||
const url = expand(definition.url, environment, `mcpServers.${serverName}.url`)
|
||||
const protocol = new URL(url).protocol
|
||||
if (protocol !== 'http:' && protocol !== 'https:') {
|
||||
throw new Error(`mcpServers.${serverName}.url must use http or https`)
|
||||
}
|
||||
return {
|
||||
transport: 'streamable-http',
|
||||
serverName,
|
||||
url,
|
||||
headers: expandMap(definition.headers, environment, `mcpServers.${serverName}.headers`),
|
||||
}
|
||||
})
|
||||
}
|
||||
109
packages/cordis/repository-plugin/tests/mcp-format.spec.ts
Normal file
109
packages/cordis/repository-plugin/tests/mcp-format.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseMcpDocument, resolveMcpServers } from '../src/mcp.ts'
|
||||
|
||||
describe('repository plugin common .mcp.json support', () => {
|
||||
it('maps Expo-style HTTP servers to the existing Streamable HTTP client config', () => {
|
||||
const document = parseMcpDocument(JSON.stringify({
|
||||
mcpServers: {
|
||||
expo: { type: 'http', url: 'https://mcp.expo.dev/mcp' },
|
||||
},
|
||||
}))
|
||||
|
||||
expect(resolveMcpServers(document, {}, '/plugin')).toEqual([{
|
||||
transport: 'streamable-http',
|
||||
serverName: 'expo',
|
||||
url: 'https://mcp.expo.dev/mcp',
|
||||
headers: {},
|
||||
}])
|
||||
})
|
||||
|
||||
it('maps DataJunction-style stdio servers and expands exact environment placeholders', () => {
|
||||
const document = parseMcpDocument(JSON.stringify({
|
||||
mcpServers: {
|
||||
datajunction: {
|
||||
command: 'dj-mcp',
|
||||
args: ['--endpoint', '${DJ_API_URL}'],
|
||||
env: { DJ_API_URL: '${DJ_API_URL}' },
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
expect(resolveMcpServers(document, { DJ_API_URL: 'http://localhost:8000' }, '/plugin')).toEqual([{
|
||||
transport: 'stdio',
|
||||
serverName: 'datajunction',
|
||||
command: 'dj-mcp',
|
||||
args: ['--endpoint', 'http://localhost:8000'],
|
||||
env: { DJ_API_URL: 'http://localhost:8000' },
|
||||
cwd: '/plugin',
|
||||
}])
|
||||
})
|
||||
|
||||
it('fails loud when a declared environment value is absent', () => {
|
||||
const document = parseMcpDocument(JSON.stringify({
|
||||
mcpServers: { datajunction: { command: 'dj-mcp', env: { DJ_API_URL: '${DJ_API_URL}' } } },
|
||||
}))
|
||||
|
||||
expect(() => resolveMcpServers(document, {}, '/plugin')).toThrow('missing environment variable DJ_API_URL')
|
||||
})
|
||||
|
||||
it('accepts explicit stdio defaults and expands HTTP URLs and headers', () => {
|
||||
const document = parseMcpDocument(JSON.stringify({
|
||||
mcpServers: {
|
||||
local: { type: 'stdio', command: 'local-mcp' },
|
||||
remote: {
|
||||
type: 'http',
|
||||
url: 'http://${MCP_HOST}/mcp',
|
||||
headers: { Authorization: 'Bearer ${MCP_TOKEN}' },
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
expect(resolveMcpServers(document, { MCP_HOST: 'localhost:3000', MCP_TOKEN: 'test-token' }, '/plugin')).toEqual([
|
||||
{
|
||||
transport: 'stdio',
|
||||
serverName: 'local',
|
||||
command: 'local-mcp',
|
||||
args: [],
|
||||
env: {},
|
||||
cwd: '/plugin',
|
||||
},
|
||||
{
|
||||
transport: 'streamable-http',
|
||||
serverName: 'remote',
|
||||
url: 'http://localhost:3000/mcp',
|
||||
headers: { Authorization: 'Bearer test-token' },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects malformed JSON, server names, placeholders, and non-HTTP URLs', () => {
|
||||
expect(() => parseMcpDocument('{')).toThrow('expected JSON')
|
||||
expect(() => parseMcpDocument(JSON.stringify({
|
||||
mcpServers: { 'bad name': { command: 'server' } },
|
||||
}))).toThrow('server name')
|
||||
expect(() => parseMcpDocument(JSON.stringify({
|
||||
mcpServers: { bad: { command: '${BAD-NAME}' } },
|
||||
}))).toThrow('unsupported environment placeholder')
|
||||
expect(() => parseMcpDocument(JSON.stringify({
|
||||
mcpServers: { bad: { command: '${UNFINISHED' } },
|
||||
}))).toThrow('unterminated environment placeholder')
|
||||
const ftp = parseMcpDocument(JSON.stringify({
|
||||
mcpServers: { remote: { type: 'http', url: 'ftp://example.test/mcp' } },
|
||||
}))
|
||||
expect(() => resolveMcpServers(ftp, {}, '/plugin')).toThrow('must use http or https')
|
||||
})
|
||||
|
||||
it('rejects Work IQ OAuth fields instead of treating them as unauthenticated HTTP', () => {
|
||||
expect(() => parseMcpDocument(JSON.stringify({
|
||||
mcpServers: {
|
||||
workiq: {
|
||||
type: 'http',
|
||||
url: 'https://workiq.microsoft.com/mcp',
|
||||
oauthClientId: 'client-id',
|
||||
oauthPublicClient: true,
|
||||
auth: { redirectPort: 3317 },
|
||||
},
|
||||
},
|
||||
}))).toThrow('invalid .mcp.json')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,243 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, relative, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as RepositoryPlugin from '@deepseek-ai/dsh-repository-plugin'
|
||||
import * as RepositoryPluginInvariant from '@deepseek-ai/dsh-repository-plugin/invariant'
|
||||
import { parsePreparedPluginConfig } from '../src/format.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
async function temporaryDirectory(name: string): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), `dsh-repository-plugin-${name}-`))
|
||||
roots.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
async function writePlugin(root: string, name: string, dsh: Record<string, unknown>): Promise<string> {
|
||||
const directory = join(root, '.dsh-plugin')
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(join(directory, 'package.json'), `${JSON.stringify({ name, version: '0.0.0', dsh }, undefined, 2)}\n`)
|
||||
return directory
|
||||
}
|
||||
|
||||
async function writeSkill(root: string, name: string): Promise<void> {
|
||||
const directory = join(root, name)
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(join(directory, 'SKILL.md'), `---\nname: ${name}\ndescription: Repository fixture skill.\n---\n\nStatic instructions.\n`)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('dsh-plugin-prepare', () => {
|
||||
it('copies declared static assets and emits the fixed import-free wrapper', async () => {
|
||||
const root = await temporaryDirectory('prepare')
|
||||
await writeSkill(join(root, 'skills'), 'repository-fixture')
|
||||
await writeFile(join(root, '.mcp.json'), JSON.stringify({
|
||||
mcpServers: {
|
||||
expo: { type: 'http', url: 'https://mcp.expo.dev/mcp' },
|
||||
},
|
||||
}))
|
||||
const directory = await writePlugin(root, 'fixture-plugin', {
|
||||
skills: ['../skills'],
|
||||
mcpServers: '../.mcp.json',
|
||||
})
|
||||
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(directory)).resolves.toEqual({
|
||||
name: 'fixture-plugin',
|
||||
skills: ['dsh-plugin-assets/skills/0'],
|
||||
mcpServers: 'dsh-plugin-assets/.mcp.json',
|
||||
})
|
||||
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/)
|
||||
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'))
|
||||
.resolves.toContain('mcp.expo.dev')
|
||||
})
|
||||
|
||||
it('rejects unsupported OAuth MCP metadata before publishing outputs', async () => {
|
||||
const root = await temporaryDirectory('oauth')
|
||||
await writeFile(join(root, '.mcp.json'), JSON.stringify({
|
||||
mcpServers: {
|
||||
workiq: {
|
||||
type: 'http',
|
||||
url: 'https://workiq.microsoft.com/mcp',
|
||||
oauthClientId: 'client-id',
|
||||
oauthPublicClient: true,
|
||||
auth: { redirectPort: 3317 },
|
||||
},
|
||||
},
|
||||
}))
|
||||
const directory = await writePlugin(root, 'unsupported-oauth', { mcpServers: '../.mcp.json' })
|
||||
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(directory)).rejects.toThrow('invalid .mcp.json')
|
||||
await expect(readFile(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('rejects invalid metadata, missing assets, wrong asset types, and escaped paths', async () => {
|
||||
const malformedRoot = await temporaryDirectory('malformed-package')
|
||||
const malformed = join(malformedRoot, '.dsh-plugin')
|
||||
await mkdir(malformed)
|
||||
await writeFile(join(malformed, 'package.json'), '{')
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(malformed)).rejects.toThrow('failed to read DSH plugin package metadata')
|
||||
|
||||
const emptyRoot = await temporaryDirectory('empty-metadata')
|
||||
const empty = await writePlugin(emptyRoot, 'empty', {})
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(empty)).rejects.toThrow('declare at least one skill root or mcpServers file')
|
||||
|
||||
const missingRoot = await temporaryDirectory('missing-asset')
|
||||
const missing = await writePlugin(missingRoot, 'missing', { skills: ['../missing'] })
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(missing)).rejects.toThrow('asset does not exist')
|
||||
|
||||
const absoluteRoot = await temporaryDirectory('absolute-asset')
|
||||
const absolute = await writePlugin(absoluteRoot, 'absolute', { skills: [absoluteRoot] })
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(absolute)).rejects.toThrow('asset path must be relative')
|
||||
|
||||
const wrongTypeRoot = await temporaryDirectory('wrong-type')
|
||||
await writeFile(join(wrongTypeRoot, 'not-a-directory'), 'text')
|
||||
const wrongType = await writePlugin(wrongTypeRoot, 'wrong-type', { skills: ['../not-a-directory'] })
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(wrongType)).rejects.toThrow('asset is not a directory')
|
||||
|
||||
const wrongMcpRoot = await temporaryDirectory('wrong-mcp-type')
|
||||
await mkdir(join(wrongMcpRoot, 'not-a-file'))
|
||||
const wrongMcp = await writePlugin(wrongMcpRoot, 'wrong-mcp', { mcpServers: '../not-a-file' })
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(wrongMcp)).rejects.toThrow('asset is not a file')
|
||||
|
||||
const containingRoot = await temporaryDirectory('containing-root')
|
||||
const containing = await writePlugin(containingRoot, 'containing', { skills: ['..'] })
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(containing)).rejects.toThrow('cannot contain the .dsh-plugin package')
|
||||
|
||||
const escapedRoot = await temporaryDirectory('escaped-root')
|
||||
const outside = await temporaryDirectory('outside-root')
|
||||
await writeSkill(outside, 'outside-skill')
|
||||
const escaped = await writePlugin(escapedRoot, 'escaped', { skills: [relative(join(escapedRoot, '.dsh-plugin'), outside)] })
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(escaped)).rejects.toThrow('escapes its plugin source root')
|
||||
})
|
||||
|
||||
it('validates prepared wrapper configs with and without MCP assets', () => {
|
||||
expect(() => parsePreparedPluginConfig({})).toThrow('invalid prepared DSH plugin')
|
||||
expect(parsePreparedPluginConfig({
|
||||
baseUrl: 'file:///plugin/dsh-plugin.mjs',
|
||||
manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json' },
|
||||
})).toEqual({
|
||||
baseUrl: 'file:///plugin/dsh-plugin.mjs',
|
||||
manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepared repository plugin Loader composition', () => {
|
||||
it('mounts and removes copied skills through the real Loader and skill-local provider', async () => {
|
||||
const root = await temporaryDirectory('loader')
|
||||
await writeSkill(join(root, 'skills'), 'loaded-from-repository')
|
||||
const directory = await writePlugin(root, 'loader-fixture', { skills: ['../skills'] })
|
||||
await RepositoryPlugin.prepareDshPlugin(directory)
|
||||
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(directory).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
await ctx.plugin(SkillService)
|
||||
const registrar = ctx.plugin(RepositoryPlugin)
|
||||
await registrar
|
||||
expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeDefined()
|
||||
|
||||
const id = await ctx.loader.create({
|
||||
name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href,
|
||||
})
|
||||
await ctx.loader.await()
|
||||
await expect(ctx.skills.get('loaded-from-repository')).resolves.toMatchObject({
|
||||
name: 'loaded-from-repository',
|
||||
provider: 'repository:loader-fixture',
|
||||
content: 'Static instructions.',
|
||||
})
|
||||
|
||||
await ctx.loader.remove(id)
|
||||
await expect(ctx.skills.get('loaded-from-repository')).resolves.toBeUndefined()
|
||||
await registrar.dispose()
|
||||
expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('delegates an MCP-only plugin to the existing client without turning connect failure into Loader failure', async () => {
|
||||
const root = await temporaryDirectory('mcp-loader')
|
||||
await writeFile(join(root, '.mcp.json'), JSON.stringify({
|
||||
mcpServers: { offline: { command: join(root, 'missing-mcp-command') } },
|
||||
}))
|
||||
const directory = await writePlugin(root, 'mcp-loader-fixture', { mcpServers: '../.mcp.json' })
|
||||
await RepositoryPlugin.prepareDshPlugin(directory)
|
||||
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(directory).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(RepositoryPlugin)
|
||||
const id = await ctx.loader.create({
|
||||
name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href,
|
||||
})
|
||||
await ctx.loader.await()
|
||||
expect(ctx.tools.schemas().some(tool => tool.name.startsWith('mcp__offline__'))).toBe(false)
|
||||
await ctx.loader.remove(id)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects hostile prepared paths before mounting children', async () => {
|
||||
const root = await temporaryDirectory('prepared-paths')
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(root).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
await ctx.plugin(RepositoryPlugin)
|
||||
|
||||
for (const [filename, skillPath] of [
|
||||
['absolute.mjs', resolve(root)],
|
||||
['escaped.mjs', '../outside'],
|
||||
] 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: 'hostile', skills: [${JSON.stringify(skillPath)}] },`,
|
||||
' })',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
await expect(ctx.loader.create({ name: pathToFileURL(wrapper).href })).rejects.toThrow('prepared DSH plugin path')
|
||||
}
|
||||
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)
|
||||
const registrar = ctx.plugin(RepositoryPlugin)
|
||||
await registrar
|
||||
expect(() => { RepositoryPlugin.apply(ctx) }).toThrow('already registered')
|
||||
|
||||
const replacement = { name: 'replacement', apply() {} }
|
||||
ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN] = replacement
|
||||
await registrar.dispose()
|
||||
expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBe(replacement)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('repository plugin invariant companion', () => {
|
||||
it('registers its explained empty invariant', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(RepositoryPluginInvariant).await()).resolves.toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
30
packages/cordis/repository-plugin/tsconfig.json
Normal file
30
packages/cordis/repository-plugin/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../../skill/skill-local"
|
||||
},
|
||||
{
|
||||
"path": "../../mcp/mcp-client"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
17
packages/cordis/repository-plugin/tsdown.config.ts
Normal file
17
packages/cordis/repository-plugin/tsdown.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/** Build the runtime, invariant, and prepare executable as self-contained entries. */
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024',
|
||||
fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false,
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024',
|
||||
fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false,
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024',
|
||||
fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false,
|
||||
},
|
||||
])
|
||||
Reference in New Issue
Block a user