fix(repository-plugin): reject incomplete MCP publication

This commit is contained in:
Tianyi Cui
2026-08-09 00:50:54 +08:00
parent 29b3e3fa84
commit cc7bd4948d
20 changed files with 89 additions and 43 deletions

View File

@@ -2,5 +2,5 @@
# 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: 3a6478d5cca05684e80ce0854cb6e0b5d2f88bd4
README.zh.md: 274d11c96cb61d4d9fa4b837433994248c94bff4
README.md: 0df0680cef873d9f7078cb63d1c803e5c323f07c
README.zh.md: 04e5f8f81ba1b3e788775e9deaff6bb0b6dd1a48

View File

@@ -64,13 +64,13 @@ During exact Git installation, DSH's bundled pnpm installs the selected package
## Runtime composition
Loading this package registers one effect-scoped Loader builtin. Each generated wrapper delegates its prepared static manifest to that builtin, then imports and mounts `dsh.entry` when declared. The entry is an ordinary Cordis child Plugin: its own `inject` gates activation, startup failures reject the repository generation, and all of its effects disappear on Loader removal or rollback. The runtime likewise validates every declared skill root as an existing in-package directory before mounting — a package whose generated outputs were dropped by `files`/`.npmignore` or damaged in cache fails instead of silently losing contributions. Repository skill roots mount as uniquely named `dsh-skill-local` providers with default project/user roots excluded and watching disabled; cached package generations are immutable.
Loading this package registers one effect-scoped Loader builtin. Each generated wrapper delegates its prepared static manifest to that builtin, then imports and mounts `dsh.entry` when declared. The wrapper can statically gate only the `loader`, `skills`, and `tools` services implied by the prepared manifest; the entry's own `inject` is discovered when that child is mounted. The entry must reach `ACTIVE`, so a missing entry-only service or startup failure rejects the repository generation instead of committing an inert child, and all effects disappear on Loader removal or rollback. The runtime likewise validates every declared skill root as an existing in-package directory before mounting — a package whose generated outputs were dropped by `files`/`.npmignore` or damaged in cache fails instead of silently losing contributions. Repository skill roots mount as uniquely named `dsh-skill-local` providers with default project/user roots excluded and watching disabled; cached package generations are immutable.
## 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. Repository-declared servers enable its strict startup mode: Plugin activation waits for the initial connection and tool discovery, so the first model request observes a successful initial tool generation, while a network, child-process, or discovery failure rejects the candidate repository generation instead of silently activating without its declared tools.
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. Repository-declared servers enable its strict startup mode: Plugin activation waits for the initial connection and tool synchronization, so the first model request observes a fully registered initial tool generation, while a network, child-process, discovery, or registration failure rejects the candidate repository generation instead of silently activating without its declared tools.
## Export shape
@@ -123,5 +123,6 @@ Stable registrations preserve the owning surface's normal prefix behavior. Loadi
## Known Limitations and Deferred Work
- **No code sandbox** — `dsh.entry`, npm dependencies, and package lifecycle scripts execute with the DSH host's authority; repository trust is mandatory.
- **Entry-only service dependencies are not pre-gated** — the generated wrapper cannot declare an entry module's `inject` before importing it. Any service beyond those implied by Skills or MCP must already exist when the wrapper mounts the entry, or that repository generation rejects.
- **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.

View File

@@ -64,13 +64,13 @@ Git 传输使用宿主的常规 Git 认证。公共仓库无需凭据;私有
## 运行时组合
加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装层都把已准备的静态 manifest元数据清单委托给该 builtin再在声明了 `dsh.entry` 时导入并挂载该入口。入口是普通的 Cordis 子插件:其自有 `inject` 会门控激活,启动失败会拒绝 repository generationLoader 移除或回滚时,所有 effect 都会消失。运行时同样会在挂载前校验每个声明的 skill 根都是包内实际存在的目录——生成输出因 `files``.npmignore` 被丢弃或在缓存中损坏的包会加载失败而不是静默丢失贡献。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存包 generation 是不可变的。
加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装层都把已准备的静态 manifest元数据清单委托给该 builtin再在声明了 `dsh.entry` 时导入并挂载该入口。包装层只能静态门控已准备 manifest 所隐含的 `loader``skills``tools` 服务;入口自身的 `inject` 要到挂载该子级时才会发现。入口必须进入 `ACTIVE`,因此缺少入口专用服务或启动失败时,会拒绝 repository generation而不会提交未激活的子级;Loader 移除或回滚时,所有 effect 都会消失。运行时同样会在挂载前校验每个声明的 skill 根都是包内实际存在的目录——生成输出因 `files``.npmignore` 被丢弃或在缓存中损坏的包会加载失败而不是静默丢失贡献。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存包 generation 是不可变的。
## 通用 MCP 格式
`.mcp.json` 根对象是 `{ "mcpServers": { ... } }`。stdio 条目只接受可选的 `type: "stdio"``command``args``env`HTTP 条目只接受 `type: "http"``url``headers`。字符串值在插件加载时支持严格的 `${NAME}` 进程环境变量展开缺失变量会使该次加载失败。HTTP URL 映射到现有 MCP client 的 `streamable-http` transportstdio 条目以已准备的包目录作为 `cwd`
未知字段会被拒绝,包括 OAuth 字段与 `auth` 对象。不提供 `CLAUDE_PLUGIN_ROOT` 展开或兼容层。完成格式转换后,现有 `dsh-mcp-client` 独占 transport 创建、连接诊断、工具同步、调用和断开生命周期。Repository 声明的 server 会启用其严格启动模式:插件激活会等待初始连接与工具发现,因此首个模型请求会看到成功的初始工具 generation网络、子进程发现失败则会拒绝候选 repository generation而不是在缺少已声明工具的情况下静默激活。
未知字段会被拒绝,包括 OAuth 字段与 `auth` 对象。不提供 `CLAUDE_PLUGIN_ROOT` 展开或兼容层。完成格式转换后,现有 `dsh-mcp-client` 独占 transport 创建、连接诊断、工具同步、调用和断开生命周期。Repository 声明的 server 会启用其严格启动模式:插件激活会等待初始连接与工具同步,因此首个模型请求会看到已完整注册的初始工具 generation网络、子进程发现或注册失败则会拒绝候选 repository generation而不是在缺少已声明工具的情况下静默激活。
## 导出形状
@@ -123,5 +123,6 @@ Namespace 插件:具名导出 `name``inject``apply`、准备阶段常量
## 已知限制与暂缓事项
- **没有代码沙箱**`dsh.entry`、NPM 依赖和包生命周期脚本以 DSH 宿主权限执行;必须信任该 repository。
- **入口专用服务依赖不会预先门控**:生成的包装层无法在导入入口模块前声明其 `inject`。除 skill 或 MCP 隐含的服务外,其他任何服务在包装层挂载入口时都必须已经存在,否则该 repository generation 会被拒绝。
- **没有 MCP 认证协议**:静态 header 可以使用环境变量展开,但带 OAuth 的定义会被拒绝,私有 server 登录流程不在此实现。
- **生成资源是不可变运行时输入**repository cache generation 不受监视;必须改变 source、ref、path 或配置才能选择另一份已准备 generation。

View File

@@ -152,6 +152,7 @@ function wrapperSource(manifest: PreparedPluginManifest): string {
return [
'// Generated by dsh-plugin-prepare. Do not edit.',
`const manifest = ${JSON.stringify(manifest)}`,
'// Value mirror: Cordis const enum FiberState.ACTIVE; keep aligned with dsh-repository-plugin source.ts.',
'const FIBER_ACTIVE = 2',
`export const name = ${JSON.stringify(manifest.name)}`,
`export const inject = ${JSON.stringify(inject)}`,

View File

@@ -80,7 +80,11 @@ async function assertInstalledPackageMetadata(directory: string): Promise<void>
}
const result = installedPackageSchema.safeParse(value)
if (!result.success) {
throw new Error(`installed DSH plugin package must declare a non-empty scripts.prepack that invokes ${JSON.stringify(REPOSITORY_PLUGIN_PREPARE_COMMAND)}:\n${z.prettifyError(result.error)}`)
throw new Error([
`installed DSH plugin package must declare a non-empty scripts.prepack that invokes ${JSON.stringify(REPOSITORY_PLUGIN_PREPARE_COMMAND)}:`,
z.prettifyError(result.error),
'Clear the matching repository cache generation before retrying the same source, or select a different exact source/ref/path after fixing the package.',
].join('\n'))
}
}

View File

@@ -330,7 +330,7 @@ describe('prepared repository plugin Loader composition', () => {
await ctx.plugin(RepositoryPlugin)
await expect(ctx.loader.create({
name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href,
})).rejects.toThrow('initial connection or tool discovery failed')
})).rejects.toThrow('initial connection or tool synchronization failed')
expect(ctx.tools.schemas().some(tool => tool.name.startsWith('mcp__offline__'))).toBe(false)
await ctx.fiber.dispose()
})
@@ -593,6 +593,12 @@ describe('configured GitHub repository sources', () => {
message: expect.stringContaining('must declare a non-empty scripts.prepack') as string,
}) as Error,
})
await expect(loadPreparedRepository(ctx, { resolve: async () => root }, 'github:owner/repository#old&path:/.dsh-plugin'))
.rejects.toMatchObject({
cause: expect.objectContaining({
message: expect.stringContaining('Clear the matching repository cache generation') as string,
}) as Error,
})
await ctx.fiber.dispose()
})