refactor(packages): merge timeout/ into guard/, rename cordis/ to self-modification/
git mv timeout-policy beside repeat-tool-guard (both are loop-hygiene policies on the tool-execution pipeline, and the timeout/ group name collided with util/timeout) and tool-cordis into self-modification/ (naming the role the framework name obscured). Merged/renamed group README triplets, tsconfig globs, generator sources, hierarchy tables, catalogs, and the timeout-policy design note's group references follow. Adds the fifth FIXME marker (dsh-timeout-guard, recorded as a suggestion to settle at resolution time). guard + self-modification suites: 197 passed.
This commit is contained in:
@@ -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/self-modification/repository-plugin/README.md
|
||||
README.md: 33cd763d7dbe21b72f9e604b7b2e313081cf656f
|
||||
README.zh.md: 903dfbe601cc76acb0c1e87453dc03ef0321409b
|
||||
@@ -0,0 +1,102 @@
|
||||
# @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.
|
||||
|
||||
## Standalone app configuration
|
||||
|
||||
The shipped `dsh-base` bundle every profile starts from contains an empty `repository-plugins` row. A user enables exact GitHub generations by replacing that row's config in a user patch layer — `$DSH_HOME/profiles/<name>/cordis.patch.yml`, or the home-level `$DSH_HOME/cordis.patch.yml` shared by every profile; a `--patch` overlay patches the same row for one run:
|
||||
|
||||
```yaml
|
||||
- id: repository-plugins
|
||||
name: '@deepseek-ai/dsh-repository-plugin'
|
||||
config:
|
||||
repositories:
|
||||
- 'github:PolyArch/humanize#<commit>'
|
||||
- 'github:owner/repository#<ref>&path:/plugins/one/.dsh-plugin'
|
||||
```
|
||||
|
||||
Each source must use `github:owner/repository#<ref>`. Omitting `&path:` selects `/.dsh-plugin`; an explicit path is absolute within the repository and must end in `.dsh-plugin`. A commit ref gives the clearest immutable identity, while tags and branches remain accepted exact config values. `cacheDir` may override the default `$DSH_HOME/cache/repository-plugins` cache root.
|
||||
|
||||
Long-lived surfaces watch both `cordis.patch.yml` layers through Cordis HMR. A valid source-list change installs and swaps the complete repository Plugin generation; a failed fetch, prepare, import, or Plugin application keeps the last good tree and broadcasts `hmr/config-update-failed(filename, error)`. One-shot runs read the layers only at startup, and a `--patch` overlay is never watched. An identical source string permanently reuses its prepared cache entry, so selecting changed code requires a ref, path, or other source-config change. App integration rationale: [config-only repository Plugins Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md).
|
||||
|
||||
## 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. The runtime validates every declared skill root as an existing in-package directory before mounting — a package whose generated outputs were dropped (a `files`/`.npmignore` mistake, a damaged cache entry) fails the plugin load instead of silently mounting a skill-less plugin. 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.
|
||||
@@ -0,0 +1,102 @@
|
||||
# @deepseek-ai/dsh-repository-plugin
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
这是 DeepSeek Harness 的受限 repository 插件格式。仓库作者在 `.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 插件格式 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md)。
|
||||
|
||||
## 创作格式
|
||||
|
||||
在仓库的 `.dsh-plugin` 目录中放置一个普通包:
|
||||
|
||||
```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` 包。
|
||||
|
||||
## 独立应用配置
|
||||
|
||||
每个 profile 都以之为起点的随附 `dsh-base` 组合包包含一个空 `repository-plugins` 配置项。用户可在用户 patch 层中替换该配置项的配置来启用精确指定的 GitHub generation:写入 `$DSH_HOME/profiles/<name>/cordis.patch.yml`,或写入各 profile 共享的 home 级 `$DSH_HOME/cordis.patch.yml`;`--patch` overlay 则只为单次运行 patch 同一配置项:
|
||||
|
||||
```yaml
|
||||
- id: repository-plugins
|
||||
name: '@deepseek-ai/dsh-repository-plugin'
|
||||
config:
|
||||
repositories:
|
||||
- 'github:PolyArch/humanize#<commit>'
|
||||
- 'github:owner/repository#<ref>&path:/plugins/one/.dsh-plugin'
|
||||
```
|
||||
|
||||
每个源都必须采用 `github:owner/repository#<ref>`。省略 `&path:` 时选择 `/.dsh-plugin`;显式路径是仓库内的绝对路径,并且必须以 `.dsh-plugin` 结尾。commit ref 提供最清晰的不可变身份;tag 和 branch 仍可作为精确配置值使用。`cacheDir` 可覆盖默认缓存根 `$DSH_HOME/cache/repository-plugins`。
|
||||
|
||||
长期运行的 surface 通过 Cordis HMR(热模块替换)监视两个 `cordis.patch.yml` 层。有效的源列表变更会安装并替换整套 repository Plugin generation;拉取、准备、导入或插件应用失败时,最后一个可用树保持运行,并广播 `hmr/config-update-failed(filename, error)`。一次性运行只在启动时读取这些层,`--patch` overlay 则从不被监视。相同的源字符串会永久复用其已准备缓存条目,因此必须改变 ref、路径或其他源配置,才能选择发生变化的代码。应用集成依据见[仅凭配置接入 repository Plugin 的 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md)。
|
||||
|
||||
## 准备阶段
|
||||
|
||||
`dsh-plugin-prepare` 校验 `package.json#dsh`、确认 skill 根类型、解析 MCP 文件、把资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。包装模块只包含规范化后的静态 manifest(元数据清单),以及查找 `dsh-repository-plugin` Loader builtin 的固定代码;它不会发现或编译仓库 JavaScript,运行时也不会导入仓库的其他入口。
|
||||
|
||||
外层包管理器仍会运行已配置仓库包的生命周期脚本。这里的限制只定义 DSH 所支持的贡献表面;对于用户选择以可执行包管理器源安装的仓库,它并不是安全边界。
|
||||
|
||||
## 运行时组合
|
||||
|
||||
加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装模块都把自身模块 URL 和已准备的 manifest 委托给该 builtin。运行时在挂载前会校验每个声明的 skill 根都是包内实际存在的目录——生成输出被丢弃的包(`files`/`.npmignore` 配置失误、缓存条目损坏)会使插件加载失败,而不是静默挂载一个没有 skill 的插件。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存包 generation 是不可变的。包装模块 dispose(资源释放)时,会通过正常的 Cordis 子 fiber teardown 移除提供方和所有组合的 MCP client。
|
||||
|
||||
## 通用 MCP 格式
|
||||
|
||||
`.mcp.json` 根对象是 `{ "mcpServers": { ... } }`。stdio 条目只接受可选的 `type: "stdio"`、`command`、`args` 和 `env`;HTTP 条目只接受 `type: "http"`、`url` 和 `headers`。字符串值在插件加载时支持严格的 `${NAME}` 进程环境变量展开;缺失变量会使该次加载失败。HTTP URL 映射到现有 MCP client 的 `streamable-http` transport;stdio 条目以已准备的包目录作为 `cwd`。
|
||||
|
||||
未知字段会被拒绝,包括 OAuth 字段与 `auth` 对象。不提供 `CLAUDE_PLUGIN_ROOT` 展开或兼容层。完成格式转换后,现有 `dsh-mcp-client` 独占 transport 创建、连接诊断、工具同步、调用和断开生命周期;网络或子进程连接失败沿用该 client 既有的“记录错误且不注册工具”行为。
|
||||
|
||||
## 导出形状
|
||||
|
||||
Namespace 插件:具名导出 `name`/`inject`/`apply`、准备阶段常量和 `prepareDshPlugin`,不提供 default export。本包还提供 `dsh-plugin-prepare` 可执行文件和 invariant companion。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### Repository skill
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
通过 `dsh-tool-skill` 间接呈现:已准备且允许模型调用的 skill 会按其声明的名称和描述进入该消费方记录到日志的目录及所选指令正文表面。消费方的确切 schema 见生成的 [`skill` 工具目录](../../../docs/tool-catalog.md#deepseek-aidsh-tool-skill)。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
有条件且随数据变化:每个可见的 repository skill 增加一行受限长度的目录项;加载一个 skill 会把其当前完整指令正文和资源基址指引加入保留的工具历史。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
稳定的已准备插件集合保持前缀稳定。添加、移除或替换 repository 插件可能使消费方追加替换目录,并影响后续请求前缀。
|
||||
|
||||
### Repository MCP 工具
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
通过 `dsh-mcp-client` 间接呈现:每个已连接 server 都贡献带 server 限定名的工具 schema;调用会保留该 client 的规范 MCP 结果和渲染。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
取决于连接成功和远端工具列表;schema 会在当前工具视图中的请求上重复出现,而调用与结果会留在历史中直至压缩(compaction)。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
稳定的已连接工具列表保持前缀稳定。插件生命周期或 MCP 工具列表变化可能从首个受影响定义开始改变后续工具 schema 前缀。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **仅支持 skill 与 MCP**:commands、钩子、agent(智能体)、apps、任意 Cordis 代码、marketplace 和兼容 shim 均有意排除在该格式之外。
|
||||
- **没有 MCP 认证协议**:静态 header 可以使用环境变量展开,但带 OAuth 的定义会被拒绝,私有 server 登录流程不在此实现。
|
||||
- **生成资源是不可变运行时输入**:repository cache generation 不受监视;必须改变 source、ref、path 或配置才能选择另一份已准备 generation。
|
||||
@@ -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"
|
||||
],
|
||||
"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-paths": "^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-paths": "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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* 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({
|
||||
// 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()
|
||||
|
||||
/** 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 },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
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 {
|
||||
// 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 = ${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}`)})`,
|
||||
' 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.
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Restricted repository-plugin runtime for static skills and common MCP definitions.
|
||||
* @module @deepseek-ai/dsh-repository-plugin
|
||||
*/
|
||||
|
||||
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'
|
||||
import { RepositoryCache } from '@cordisjs/plugin-loader/repository'
|
||||
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
||||
import * as McpClient from '@deepseek-ai/dsh-mcp-client'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
REPOSITORY_PLUGIN_BUILTIN,
|
||||
isOutside,
|
||||
parsePreparedPluginConfig,
|
||||
type PreparedPluginConfig,
|
||||
} from './format.ts'
|
||||
import { parseMcpDocument, resolveMcpServers } from './mcp.ts'
|
||||
import {
|
||||
loadPreparedRepository,
|
||||
resolveRepositoryCacheDirectory,
|
||||
resolveRepositorySpecifier,
|
||||
} from './source.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']
|
||||
|
||||
/** Repository Plugin runtime and source-list configuration. */
|
||||
export interface Config {
|
||||
/** GitHub repository sources with explicit refs and optional `.dsh-plugin` subpaths. */
|
||||
repositories?: string[]
|
||||
/** Persistent generation cache; defaults to `$DSH_HOME/cache/repository-plugins`. */
|
||||
cacheDir?: string
|
||||
}
|
||||
|
||||
export const Config = z.object({
|
||||
repositories: z.array(z.string().min(1)).default([]),
|
||||
cacheDir: z.string().min(1).optional(),
|
||||
}).strict().default({ repositories: [] })
|
||||
|
||||
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)
|
||||
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 = 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* () {
|
||||
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 async function apply(ctx: Context, config: Config = {}): Promise<void> {
|
||||
if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] !== undefined) {
|
||||
throw new Error(`Loader builtin ${REPOSITORY_PLUGIN_BUILTIN} is already registered`)
|
||||
}
|
||||
const repositories = (config.repositories ?? []).map(resolveRepositorySpecifier)
|
||||
if (new Set(repositories).size !== repositories.length) {
|
||||
throw new Error('repository sources must resolve to unique exact specifiers')
|
||||
}
|
||||
const cache = new RepositoryCache(resolveRepositoryCacheDirectory(config.cacheDir))
|
||||
await ctx.effect(async 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)
|
||||
}
|
||||
}
|
||||
for (const repository of repositories) {
|
||||
const plugin = await loadPreparedRepository(ctx, cache, repository)
|
||||
yield plugin.dispose
|
||||
}
|
||||
}, 'repository-plugin runtime and sources')
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Parser for the common `.mcp.json` file consumed by prepared repository plugins.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
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 ${SERVER_NAME_PATTERN.source}`)
|
||||
}
|
||||
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`),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* GitHub repository source validation and prepared-wrapper loading.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import type { Context, Fiber, FiberState, Plugin } from 'cordis'
|
||||
import type { RepositoryCache } from '@cordisjs/plugin-loader/repository'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { PREPARED_ENTRY_FILENAME } from './format.ts'
|
||||
|
||||
// Value mirror: Cordis's const enum has no runtime object to import. Keep
|
||||
// aligned with `packages/self-modification/tool-cordis/src/fiber-state.ts`.
|
||||
const FIBER_ACTIVE = 2 as FiberState.ACTIVE
|
||||
|
||||
/** Directory under the Harness home containing immutable repository generations. */
|
||||
export const DEFAULT_REPOSITORY_CACHE_DIRECTORY = 'repository-plugins'
|
||||
|
||||
// The ref segment excludes `#` so `github:o/r#a#b` fails here — at the config
|
||||
// parser, with the syntax the error message promises — instead of inside the
|
||||
// cache's pnpm install ('misconfiguration fails loud at the earliest
|
||||
// resolvable point').
|
||||
const GITHUB_SOURCE_PATTERN = /^github:([^/\s#&]+)\/([^/\s#&]+)#([^\s#&]+)(?:&path:(\/[^\s&]+))?$/
|
||||
|
||||
function validPluginPath(path: string): boolean {
|
||||
const segments = path.split('/').slice(1)
|
||||
return segments.length > 0
|
||||
&& segments.at(-1) === '.dsh-plugin'
|
||||
&& segments.every(segment => segment.length > 0 && segment !== '.' && segment !== '..')
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize one user-facing GitHub source to the exact pnpm dependency specifier.
|
||||
* @param configured - `github:owner/repo#ref` with an optional `&path:/.../.dsh-plugin`.
|
||||
* @returns the exact specifier, with the root `.dsh-plugin` subpath added when omitted.
|
||||
* @throws when the GitHub owner, repository, explicit ref, or plugin subpath is invalid.
|
||||
*/
|
||||
export function resolveRepositorySpecifier(configured: string): string {
|
||||
const match = GITHUB_SOURCE_PATTERN.exec(configured)
|
||||
if (match === null) {
|
||||
throw new Error(`repository source must use github:owner/repo#<ref> with an optional &path:/.../.dsh-plugin: ${JSON.stringify(configured)}`)
|
||||
}
|
||||
const path = match[4]
|
||||
if (path !== undefined && !validPluginPath(path)) {
|
||||
throw new Error(`repository source path must be an absolute repository subpath ending in .dsh-plugin without empty, . or .. segments: ${JSON.stringify(path)}`)
|
||||
}
|
||||
return path === undefined ? `${configured}&path:/.dsh-plugin` : configured
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the persistent repository cache root.
|
||||
* @param configured - explicit cache directory, or undefined for `$DSH_HOME/cache/repository-plugins`.
|
||||
* @returns an absolute cache directory.
|
||||
*/
|
||||
export function resolveRepositoryCacheDirectory(configured: string | undefined): string {
|
||||
return resolve(configured ?? join(resolveDshHome(), 'cache', DEFAULT_REPOSITORY_CACHE_DIRECTORY))
|
||||
}
|
||||
|
||||
/**
|
||||
* Load one exact repository generation's generated wrapper as a child Cordis fiber.
|
||||
* @param ctx - repository runtime context that owns the child.
|
||||
* @param cache - package-manager-native immutable repository cache.
|
||||
* @param specifier - normalized exact pnpm dependency specifier.
|
||||
* @returns the settled prepared-wrapper fiber.
|
||||
* @throws when installation, wrapper import, manifest validation, or child registration fails.
|
||||
*/
|
||||
export async function loadPreparedRepository(
|
||||
ctx: Context,
|
||||
cache: Pick<RepositoryCache, 'resolve'>,
|
||||
specifier: string,
|
||||
): Promise<Fiber> {
|
||||
const directory = await cache.resolve(specifier)
|
||||
const filename = join(directory, PREPARED_ENTRY_FILENAME)
|
||||
try {
|
||||
const plugin = await import(/* @vite-ignore */pathToFileURL(filename).href) as Plugin
|
||||
const fiber = ctx.plugin(plugin)
|
||||
await fiber
|
||||
// Awaiting a service-gated fiber returns while it is still PENDING (the
|
||||
// generated wrapper injects `skills`/`tools` per its manifest). This
|
||||
// runtime commits the repository configuration transactionally, so a
|
||||
// composition that never provides a required service must reject the
|
||||
// transaction here — not settle ACTIVE with a silently pending child.
|
||||
if (fiber.state !== FIBER_ACTIVE) {
|
||||
const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)
|
||||
/* v8 ignore next 2 -- the 'unknown' arm needs a service to appear after the state read; not deterministically stageable. */
|
||||
const detail = missing.join(', ') || 'unknown'
|
||||
throw new Error(`prepared wrapper did not activate (waiting for services: ${detail})`)
|
||||
}
|
||||
return await fiber
|
||||
} catch (cause) {
|
||||
throw new Error(`failed to load prepared repository Plugin ${JSON.stringify(specifier)} from ${filename}`, { cause })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
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: {
|
||||
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,457 @@
|
||||
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, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { RepositoryCache } from '@cordisjs/plugin-loader/repository'
|
||||
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'
|
||||
import {
|
||||
loadPreparedRepository,
|
||||
resolveRepositoryCacheDirectory,
|
||||
resolveRepositorySpecifier,
|
||||
} from '../src/source.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 () => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
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}"]`)
|
||||
// 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'))
|
||||
.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('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)
|
||||
const registrar = ctx.plugin(RepositoryPlugin)
|
||||
await registrar
|
||||
await expect(RepositoryPlugin.apply(ctx)).rejects.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('configured GitHub repository sources', () => {
|
||||
it('defaults an omitted source list and rejects unknown configuration fields', () => {
|
||||
expect(RepositoryPlugin.Config.parse(undefined)).toEqual({ repositories: [] })
|
||||
expect(RepositoryPlugin.Config.safeParse({ repositories: [], unexpected: true }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts an empty direct-apply config', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Loader)
|
||||
await RepositoryPlugin.apply(ctx, {})
|
||||
expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('adds the root plugin subpath and preserves an explicit nested plugin subpath', () => {
|
||||
expect(resolveRepositorySpecifier('github:PolyArch/humanize#v1.0.0'))
|
||||
.toBe('github:PolyArch/humanize#v1.0.0&path:/.dsh-plugin')
|
||||
expect(resolveRepositorySpecifier('github:owner/repository#feature/ref&path:/plugins/one/.dsh-plugin'))
|
||||
.toBe('github:owner/repository#feature/ref&path:/plugins/one/.dsh-plugin')
|
||||
})
|
||||
|
||||
it('rejects absent refs and invalid plugin subpaths', () => {
|
||||
for (const source of [
|
||||
'github:owner/repository',
|
||||
'github:owner/repository#',
|
||||
'github:owner/repository#a#b',
|
||||
'https://github.com/owner/repository#ref',
|
||||
'github:owner/repository#ref&path:relative/.dsh-plugin',
|
||||
]) {
|
||||
expect(() => resolveRepositorySpecifier(source)).toThrow('must use github:owner/repo#<ref>')
|
||||
}
|
||||
for (const path of [
|
||||
'/plugins//.dsh-plugin',
|
||||
'/plugins/../.dsh-plugin',
|
||||
'/plugins/./.dsh-plugin',
|
||||
'/plugins/not-a-plugin',
|
||||
]) {
|
||||
expect(() => resolveRepositorySpecifier(`github:owner/repository#ref&path:${path}`))
|
||||
.toThrow('path must be an absolute repository subpath')
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves the default cache under DSH_HOME and an explicit cache absolutely', async () => {
|
||||
const root = await temporaryDirectory('cache-root')
|
||||
vi.stubEnv('DSH_HOME', root)
|
||||
expect(resolveRepositoryCacheDirectory(undefined)).toBe(join(root, 'cache', 'repository-plugins'))
|
||||
expect(resolveRepositoryCacheDirectory(join(root, 'explicit'))).toBe(join(root, 'explicit'))
|
||||
})
|
||||
|
||||
it('loads a configured source through the immutable cache and removes its skill on teardown', async () => {
|
||||
const root = await temporaryDirectory('configured-source')
|
||||
await writeSkill(join(root, 'skills'), 'configured-repository-skill')
|
||||
const directory = await writePlugin(root, 'configured-source-fixture', { skills: ['../skills'] })
|
||||
await RepositoryPlugin.prepareDshPlugin(directory)
|
||||
const resolved: string[] = []
|
||||
const cacheDirectory = join(root, 'cache')
|
||||
vi.spyOn(RepositoryCache.prototype, 'resolve').mockImplementation(async function (this: RepositoryCache, specifier) {
|
||||
expect(this.directory).toBe(cacheDirectory)
|
||||
resolved.push(specifier)
|
||||
return directory
|
||||
})
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Loader)
|
||||
await ctx.plugin(SkillService)
|
||||
const registrar = ctx.plugin(RepositoryPlugin, {
|
||||
repositories: ['github:owner/repository#fixed-ref'],
|
||||
cacheDir: cacheDirectory,
|
||||
})
|
||||
await registrar
|
||||
expect(resolved).toEqual(['github:owner/repository#fixed-ref&path:/.dsh-plugin'])
|
||||
await expect(ctx.skills.get('configured-repository-skill')).resolves.toMatchObject({
|
||||
provider: 'repository:configured-source-fixture',
|
||||
})
|
||||
|
||||
await registrar.dispose()
|
||||
await expect(ctx.skills.get('configured-repository-skill')).resolves.toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('swaps generations on a live source-list update and rolls a failed candidate back', async () => {
|
||||
// The headline flow: a personal-config edit reaches this plugin as a
|
||||
// Loader entry.update, which restarts the row's fiber (old cleanup, then
|
||||
// new apply — so the 'already registered' builtin guard must not fire).
|
||||
const roots: Record<string, string> = {}
|
||||
for (const generation of ['one', 'two'] as const) {
|
||||
const root = await temporaryDirectory(`live-${generation}`)
|
||||
await writeSkill(join(root, 'skills'), `live-skill-${generation}`)
|
||||
const directory = await writePlugin(root, `live-fixture-${generation}`, { skills: ['../skills'] })
|
||||
await RepositoryPlugin.prepareDshPlugin(directory)
|
||||
roots[`github:owner/repository#${generation}&path:/.dsh-plugin`] = directory
|
||||
}
|
||||
vi.spyOn(RepositoryCache.prototype, 'resolve').mockImplementation(async (specifier) => {
|
||||
const directory = roots[specifier]
|
||||
if (directory === undefined) throw new Error(`unprepared generation ${specifier}`)
|
||||
return directory
|
||||
})
|
||||
|
||||
// Route the row through the Loader builtin table exactly as a config tree
|
||||
// would; the module itself is the row's plugin.
|
||||
const ctx2 = new Context()
|
||||
await ctx2.plugin(Loader)
|
||||
await ctx2.plugin(SkillService)
|
||||
ctx2.loader.builtins['repository-plugins'] = RepositoryPlugin
|
||||
const entryId = await ctx2.loader.create({
|
||||
name: 'cordis:repository-plugins',
|
||||
config: { repositories: ['github:owner/repository#one'] },
|
||||
})
|
||||
await ctx2.loader.await()
|
||||
await expect(ctx2.skills.get('live-skill-one')).resolves.toMatchObject({ provider: 'repository:live-fixture-one' })
|
||||
|
||||
const entry = ctx2.loader.resolve(entryId)
|
||||
await entry.update({ config: { repositories: ['github:owner/repository#two'] } })
|
||||
await ctx2.loader.await()
|
||||
await expect(ctx2.skills.get('live-skill-one')).resolves.toBeUndefined()
|
||||
await expect(ctx2.skills.get('live-skill-two')).resolves.toMatchObject({ provider: 'repository:live-fixture-two' })
|
||||
|
||||
// A failed candidate (unprepared source) rejects the update and the
|
||||
// transactional Loader restores the previous generation.
|
||||
await expect(entry.update({ config: { repositories: ['github:owner/repository#missing'] } }))
|
||||
.rejects.toThrow('unprepared generation')
|
||||
await ctx2.loader.await()
|
||||
await expect(ctx2.skills.get('live-skill-two')).resolves.toMatchObject({ provider: 'repository:live-fixture-two' })
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects duplicate generations and cleans the builtin after cache preparation fails', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Loader)
|
||||
await expect(RepositoryPlugin.apply(ctx, {
|
||||
repositories: [
|
||||
'github:owner/repository#ref',
|
||||
'github:owner/repository#ref',
|
||||
],
|
||||
})).rejects.toThrow('must resolve to unique exact specifiers')
|
||||
|
||||
vi.spyOn(RepositoryCache.prototype, 'resolve').mockRejectedValue(new Error('prepare failed'))
|
||||
await expect(RepositoryPlugin.apply(ctx, {
|
||||
repositories: ['github:owner/repository#other'],
|
||||
})).rejects.toThrow('prepare failed')
|
||||
expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a wrapper left pending by a composition without its required services', async () => {
|
||||
// A skills-declaring generation mounted where no skills service exists:
|
||||
// the wrapper fiber stays PENDING, and the transaction must fail loud
|
||||
// instead of committing an ACTIVE row over a silently inert child.
|
||||
const root = await temporaryDirectory('pending-services')
|
||||
await writeSkill(join(root, 'skills'), 'pending-service-skill')
|
||||
const directory = await writePlugin(root, 'pending-service-fixture', { skills: ['../skills'] })
|
||||
await RepositoryPlugin.prepareDshPlugin(directory)
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Loader)
|
||||
// Deliberately NO SkillService.
|
||||
await expect(loadPreparedRepository(ctx, { resolve: async () => directory }, 'github:owner/repository#pending&path:/.dsh-plugin'))
|
||||
.rejects.toMatchObject({
|
||||
message: expect.stringContaining('failed to load prepared repository Plugin') as string,
|
||||
cause: expect.objectContaining({
|
||||
message: expect.stringContaining('waiting for services: skills') as string,
|
||||
}) as Error,
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('labels a missing prepared wrapper with its exact source and path', async () => {
|
||||
const root = await temporaryDirectory('missing-wrapper')
|
||||
const ctx = new Context()
|
||||
const specifier = 'github:owner/repository#missing&path:/.dsh-plugin'
|
||||
await expect(loadPreparedRepository(ctx, { resolve: async () => root }, specifier))
|
||||
.rejects.toThrow(`failed to load prepared repository Plugin ${JSON.stringify(specifier)}`)
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"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": "../../util/paths"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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