fix(review): validate skill roots at mount and isolate provider default roots

ds-review-bot round 1 on the repository-plugin runtime:
- a manifest-declared skill root absent or non-directory in the installed
  package now fails the plugin load (skill-local treats a missing root as
  legitimately empty, which silently mounted a skill-less plugin)
- includeDefaultRoots: false no longer inherits $DSH_BUNDLED_SKILL_DIR, so
  isolated repository providers see only their explicit roots
- prepared wrapper baseUrl schema requires the file: scheme, failing hostile
  URLs at the declared validation boundary
- preparedPath reuses format.ts's isOutside; SERVER_NAME_PATTERN is exported
  and pinned equal to dsh-mcp-client's, with the restatement justified (the
  prepare bin keeps a zod-only module graph); the unexplained `as never`
  cast now carries its schemastery rationale
- the import-free wrapper assertion also rejects dynamic import(
- the headless fixture wrapper is regenerated by the real prepareDshPlugin
  and a drift test pins fixture == generator output
- prepareDshPlugin JSDoc states the non-atomic publish repair contract
This commit is contained in:
Tianyi Cui
2026-08-01 21:16:23 +08:00
parent a0aed8a19f
commit 0664b25cd9
20 changed files with 175 additions and 36 deletions

View File

@@ -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 .agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md
2026-07-30-static-repository-plugin-format.md: c4739a6843db515d4cd67441e74bbf05228c612a
2026-07-30-static-repository-plugin-format.zh.md: 0b0a2af132137a820ba941869a612b7f28754fcb
2026-07-30-static-repository-plugin-format.md: f31728e28ddbb8e6403f327cb5b7c7533b214129
2026-07-30-static-repository-plugin-format.zh.md: ec2295579353632a605aeb4eb2da7a39cfc4b23a

View File

@@ -14,7 +14,7 @@ The [package-manager-native repository cache](2026-07-30-package-manager-native-
`@deepseek-ai/dsh-repository-plugin` owns a restricted `.dsh-plugin` package format with two contribution kinds only: skill roots and one common `.mcp.json`. Its package metadata uses `package.json#dsh.skills` for relative skill-root paths and `package.json#dsh.mcpServers` for the relative MCP document path. At least one is required. Each path may leave `.dsh-plugin` to reuse repository content but must remain beneath the directory containing that `.dsh-plugin`; a nested selectable Plugin therefore owns the adjacent subtree above its package without gaining access to unrelated host paths.
The `.dsh-plugin` package declares `dsh-plugin-prepare` as its ordinary package-manager `prepare` script. The helper validates metadata and source types, strictly parses `.mcp.json`, copies static assets into `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. The `.mjs` extension avoids imposing `type: module` on repository-authored package metadata. The generated module is a fixed import-free template containing only a normalized manifest, `inject = ['loader']`, and delegation to the `dsh-repository-plugin` Loader builtin. Preparation never discovers, transpiles, bundles, or preserves a custom repository entry point.
The `.dsh-plugin` package declares `dsh-plugin-prepare` as its ordinary package-manager `prepare` script. The helper validates metadata and source types, strictly parses `.mcp.json`, copies static assets into `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. The `.mjs` extension avoids imposing `type: module` on repository-authored package metadata. The generated module is a fixed import-free template containing only a normalized manifest, an `inject` list derived from it (`loader`, plus `skills` and/or `tools` per the declared capabilities, so the wrapper fiber gates on the services its children need), and delegation to the `dsh-repository-plugin` Loader builtin. Preparation never discovers, transpiles, bundles, or preserves a custom repository entry point.
Loading the DSH package registers that builtin as an effect. A generated wrapper mounts the builtin as its child with `import.meta.url`, so all contributions belong to the wrapper fiber and disappear on Loader removal or rollback. The builtin revalidates the prepared manifest and path containment before reading assets. It composes the existing implementations rather than registering skills or MCP tools itself.

View File

@@ -14,7 +14,7 @@
`@deepseek-ai/dsh-repository-plugin` 负责一个受限的 `.dsh-plugin` package 格式且只允许两类贡献skill 根和一个通用 `.mcp.json`。Package metadata 使用 `package.json#dsh.skills` 声明相对 skill 根路径,使用 `package.json#dsh.mcpServers` 声明相对 MCP 文档路径;两者至少需要一个。路径可以离开 `.dsh-plugin` 以复用仓库内容,但必须留在包含该 `.dsh-plugin` 的目录之下;因此,一个嵌套且可选择的 Plugin 可以拥有其 package 上方相邻的子树,却不能访问无关宿主路径。
`.dsh-plugin` package 把 `dsh-plugin-prepare` 声明为普通 package-manager `prepare` 脚本。Helper 会校验 metadata 与源码类型,严格解析 `.mcp.json`,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs``.mjs` 扩展名避免强迫仓库作者在 package metadata 中设置 `type: module`。生成模块来自固定、无 import 的模板,只包含规范化 manifest、`inject = ['loader']`,以及对 `dsh-repository-plugin` Loader builtin 的委托。准备阶段永远不会发现、转译、打包或保留自定义仓库入口。
`.dsh-plugin` package 把 `dsh-plugin-prepare` 声明为普通 package-manager `prepare` 脚本。Helper 会校验 metadata 与源码类型,严格解析 `.mcp.json`,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs``.mjs` 扩展名避免强迫仓库作者在 package metadata 中设置 `type: module`。生成模块来自固定、无 import 的模板,只包含规范化 manifest、由 manifest 派生的 `inject` 列表(`loader`,加上按声明能力加入的 `skills``tools`,使包装 fiber 在其子插件所需服务上门控),以及对 `dsh-repository-plugin` Loader builtin 的委托。准备阶段永远不会发现、转译、打包或保留自定义仓库入口。
加载 DSH package 会以 effect 方式注册该 builtin。生成的包装模块使用 `import.meta.url` 把 builtin 挂载为自己的子级,因此所有贡献都归属于包装 fiber并在 Loader 移除或回滚时消失。Builtin 会在读取资源前重新校验已准备 manifest 与路径包含关系。它只组合现有实现,而不自行注册 skills 或 MCP 工具。

View File

@@ -867,7 +867,7 @@ export interface StreamableHttpConfig {
}
```
Source: [`packages/mcp/mcp-client/src/index.ts:93`](../packages/mcp/mcp-client/src/index.ts)
Source: [`packages/mcp/mcp-client/src/index.ts:96`](../packages/mcp/mcp-client/src/index.ts)
## `@deepseek-ai/dsh-permission`
@@ -1334,7 +1334,7 @@ export interface Config {
watchMaxProjects?: number
/** Whether watched symbolic links follow their target files. */
watchFollowSymlinks?: boolean
/** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR`, otherwise mounts none. */
/** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR` when default roots are included, otherwise mounts none. */
bundledSkillDir?: string
}
```

View File

@@ -1,9 +1,9 @@
// Generated by dsh-plugin-prepare. Do not edit.
const manifest = { "name": "headless-repository-fixture", "skills": ["dsh-plugin-assets/skills/0"] }
export const name = 'headless-repository-fixture'
export const inject = ['loader']
const manifest = {"name":"headless-repository-fixture","skills":["dsh-plugin-assets/skills/0"]}
export const name = "headless-repository-fixture"
export const inject = ["loader","skills"]
export async function apply(ctx) {
const runtime = ctx.loader.builtins['dsh-repository-plugin']
if (runtime === undefined) throw new Error('missing Cordis builtin dsh-repository-plugin')
const runtime = ctx.loader.builtins["dsh-repository-plugin"]
if (runtime === undefined) throw new Error("missing Cordis builtin dsh-repository-plugin")
await ctx.plugin(runtime, { baseUrl: import.meta.url, manifest })
}

View File

@@ -1,10 +1,12 @@
import { readFile, readdir } from 'node:fs/promises'
import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { zstdDecompress } from 'node:zlib'
import { promisify } from 'node:util'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
import { PREPARED_ENTRY_FILENAME, prepareDshPlugin } from '@deepseek-ai/dsh-repository-plugin'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url))
@@ -59,4 +61,28 @@ describe('headless-agent keyless smoke', () => {
expect(String(result?.['result'])).toContain('CLI_TOOL_ROUND_TRIP')
expect(persistedHeader).toMatchObject({ type: 'session' })
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('keeps the checked-in prepared wrapper identical to the generator output for its manifest', async () => {
// The fixture claims "Generated by dsh-plugin-prepare"; this pin makes the
// claim true — a wrapper-template change fails here until the fixture is
// regenerated, so the assembled smoke can never exercise a stale shape.
const fixture = fileURLToPath(new URL('./fixtures/repository-plugin/', import.meta.url))
const root = await mkdtemp(join(tmpdir(), 'dsh-fixture-drift-'))
try {
const plugin = join(root, '.dsh-plugin')
await mkdir(plugin, { recursive: true })
await cp(join(fixture, 'dsh-plugin-assets/skills/0'), join(root, 'skills'), { recursive: true })
await writeFile(join(plugin, 'package.json'), `${JSON.stringify({
name: 'headless-repository-fixture',
version: '0.0.0',
dsh: { skills: ['../skills'] },
}, undefined, 2)}\n`)
await prepareDshPlugin(plugin)
const generated = await readFile(join(plugin, PREPARED_ENTRY_FILENAME), 'utf8')
const checkedIn = await readFile(join(fixture, PREPARED_ENTRY_FILENAME), 'utf8')
expect(checkedIn).toBe(generated)
} finally {
await rm(root, { recursive: true, force: true })
}
})
})

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/cordis/repository-plugin/README.md
README.md: dab6287304f083e5c0ae128d5a3cb861332c076a
README.zh.md: 790e601ad022ffa20c7d02a88353e972bf8bffe2
README.md: 80744eb489d1714f59ba6e53207476a8ce222e24
README.zh.md: d297b44e4a065fa99865e3a42d2c823c7b7c5848

View File

@@ -36,7 +36,7 @@ The containing package manager still runs the configured repository package's li
## 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.
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

View File

@@ -36,7 +36,7 @@
## 运行时组合
加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装模块都把自身模块 URL 和已准备的 manifest 委托给该 builtin。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存 package generation 是不可变的。包装模块 dispose 时,会通过正常的 Cordis 子 fiber teardown 移除提供方和所有组合的 MCP client。
加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装模块都把自身模块 URL 和已准备的 manifest 委托给该 builtin。运行时在挂载前会校验每个声明的 skill 根都是包内实际存在的目录——生成输出被丢弃的包(`files``.npmignore` 配置失误、缓存条目损坏)会使插件加载失败,而不是静默挂载一个没有 skill 的插件。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存 package generation 是不可变的。包装模块 dispose 时,会通过正常的 Cordis 子 fiber teardown 移除提供方和所有组合的 MCP client。
## 通用 MCP 格式

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,15 @@
import { describe, expect, it } from 'vitest'
import { parseMcpDocument, resolveMcpServers } from '../src/mcp.ts'
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: {

View File

@@ -59,7 +59,9 @@ describe('dsh-plugin-prepare', () => {
})
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/)
// 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'))
@@ -218,6 +220,34 @@ describe('prepared repository plugin Loader composition', () => {
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)

View File

@@ -36,8 +36,11 @@ const DEFAULT_TOOL_CALL_TIMEOUT_MS = 60_000
/**
* Valid `serverName`: 132 chars of `[A-Za-z0-9_-]`. Kept well under the
* 64-char public-name budget so typical raw tool names survive unhashed.
* Exported so upstream producers of Config inputs (repository-plugin's
* `.mcp.json` prepare-time validation) reject the same names this registry
* would.
*/
const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/
export const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/
/**
* Live `serverName` reservations per app, keyed off `ctx.root` (multiple apps

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/skill/skill-local/README.md
README.md: 836a2a631e9e6e452a11e3cffc102de355f1c5d9
README.zh.md: 2e2cc45ad80f760e04f813b7ee85932b51b1df05
README.md: f85cc2e6fd0c32cb88f28a2914a03e22b3a20657
README.zh.md: 73a66831ad14b7edb346227cf6adb52ec8247fd7

View File

@@ -38,7 +38,7 @@ Default roots are resolved in this provider's rank order:
| 400 | `user-dsh` | `<dshHome>/skills` |
| 500 | `user-agents` | `<agentsHome>/skills` |
The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not treated as normal user skills. `includeDefaultRoots: false` omits both project and user rows while retaining explicitly configured custom and bundled roots, allowing several uniquely named isolated providers such as immutable repository Plugins. This provider supplies project and user skills; another provider may supply built-in system skills.
The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not treated as normal user skills. `includeDefaultRoots: false` omits the project and user rows and the `$DSH_BUNDLED_SKILL_DIR` environment default while retaining explicitly configured custom and bundled roots, allowing several uniquely named isolated providers such as immutable repository Plugins to see only their own roots. This provider supplies project and user skills; another provider may supply built-in system skills.
When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and probes `.git` through the filesystem service. Full skill loads forward the lookup abort signal to filesystem metadata and content reads. Without a filesystem service, the provider falls back to abortable Node filesystem I/O so minimal local contexts can still load skills. Confirmed missing paths are valid empty state, malformed or non-text entries warn and skip, and unexpected discovery/read failures make the registry snapshot incomplete rather than replacing a last-good model catalog with a misleading deletion.

View File

@@ -38,7 +38,7 @@
| 400 | `user-dsh` | `<dshHome>/skills` |
| 500 | `user-agents` | `<agentsHome>/skills` |
项目根目录是包含 `.git` 的最近祖先目录;如果不存在,则使用当前 cwd。用户 DSH 根目录会跳过其 `.system` 子目录,因此归系统所有的目录不会被当作普通用户 skill。`includeDefaultRoots: false` 会省略项目和用户两类根,同时保留显式配置的自定义根与 bundled 根,因此可以挂载多个唯一命名隔离提供方,例如不可变 repository Plugin。该提供方提供项目和用户 skill其他提供方可提供内置系统 skill。
项目根目录是包含 `.git` 的最近祖先目录;如果不存在,则使用当前 cwd。用户 DSH 根目录会跳过其 `.system` 子目录,因此归系统所有的目录不会被当作普通用户 skill。`includeDefaultRoots: false` 会省略项目根、用户根以及 `$DSH_BUNDLED_SKILL_DIR` 环境默认值,同时保留显式配置的自定义根与 bundled 根,因此可以挂载多个只看到自身根的唯一命名隔离提供方,例如不可变 repository Plugin。该提供方提供项目和用户 skill其他提供方可提供内置系统 skill。
`ctx.fs` 可用时,发现通过 `ctx.fs.listDir` 列出根,通过 `ctx.fs.readText` 读取 skill 文件,并通过文件系统服务探测 `.git`。完整 skill 加载会将查找中止信号转发给文件系统元数据和内容读取。如果没有文件系统服务,提供方回退到可中止的 Node 文件系统 I/O使最小本地上下文仍能加载 skill。已确认缺失的路径属于有效空状态格式错误或非文本条目会警告并跳过意外的发现或读取失败会使注册表快照不完整系统不会因此用看似发生删除的结果替换上一份可用模型目录。

View File

@@ -69,7 +69,7 @@ export interface Config {
watchMaxProjects?: number
/** Whether watched symbolic links follow their target files. */
watchFollowSymlinks?: boolean
/** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR`, otherwise mounts none. */
/** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR` when default roots are included, otherwise mounts none. */
bundledSkillDir?: string
}
@@ -165,7 +165,12 @@ export class LocalSkillProvider implements SkillProvider {
this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root))
this.watchManager = new SkillWatchManager(ctx, control.invalidate, resolveWatchConfig(config))
control.signal.addEventListener('abort', () => { void this.dispose() }, { once: true })
const bundledSkillDir = config.bundledSkillDir ?? process.env.DSH_BUNDLED_SKILL_DIR
// The environment bundled root is a default root: an isolated provider
// (includeDefaultRoots: false — repository plugins) must see only its
// explicit custom roots, or every such provider would re-discover the
// app's bundled skills and claim them under its own provider name.
const bundledSkillDir = config.bundledSkillDir
?? (this.includeDefaultRoots ? process.env.DSH_BUNDLED_SKILL_DIR : undefined)
this.bundledSkillDir = bundledSkillDir === undefined ? undefined : resolve(bundledSkillDir)
}

View File

@@ -820,6 +820,22 @@ describe('LocalSkillProvider', () => {
await ctx.plugin(SkillLocal, { watch: false })
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-bundled-skill', 'env-skill'])
// Isolated providers see only their explicit roots: the environment
// bundled root is a default root, so includeDefaultRoots: false must
// drop it — repository providers never re-claim the app's builtins.
const isolated = new Context()
await isolated.plugin(SkillService)
const customOnly = join(envHome, 'custom-only')
await writeSkill(customOnly, 'custom-isolated-skill', 'Custom isolated skill')
await isolated.plugin(SkillLocal, {
providerName: 'isolated',
includeDefaultRoots: false,
customSkillDirs: [customOnly],
watch: false,
})
expect((await isolated.skills.list()).map(skill => skill.name)).toEqual(['custom-isolated-skill'])
await isolated.fiber.dispose()
process.env.DSH_HOME = join(envHome, 'empty-dsh')
delete process.env.DSH_BUNDLED_SKILL_DIR
process.env.DSH_AGENTS_HOME = join(envHome, 'empty-agents')