Merge remote-tracking branch 'origin/master' into fix/continuable-subagent-policy-inheritance

# Conflicts:
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl
This commit is contained in:
Hypatia May
2026-08-10 22:02:36 +08:00
104 changed files with 160 additions and 3626 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/README.md
README.md: c246534a26cd7297f2ba8885099c8b517a5dc2b0
README.zh.md: 4139874d3ebbf82fad8680a3721a1cf35a553706
README.md: 19d6e5ba7b554f59bd66e213f8a53389761fc735
README.zh.md: 17f58a2922e9019af054b0dccb6c4d9199fd1a9d

View File

@@ -38,7 +38,7 @@ Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Gr
| [`preset/`](preset/README.md) | Per-session agent composition from preset `cordis.yml` files | Product — stable surface |
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders + the `tools/execute` deadline enforcer | Product — stable surface |
| [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable surface |
| [`self-modification/`](self-modification/README.md) | Agent runtime self-modification: live plugin/service inspection, model-written plugin mount/unmount ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)), restricted repository Plugin loading | Product — stable surface |
| [`self-modification/`](self-modification/README.md) | Agent runtime self-modification: live plugin/service inspection and model-written plugin mount/unmount ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
| [`session/`](session/README.md) | Durable session data plane: persistence seam + JSONL/SQLite backends, projection seam, log-backed titles, session reporting | Product — stable surface |
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |

View File

@@ -38,7 +38,7 @@ npm scope 为 `@deepseek-ai/dsh-*`Cordis `Service` 子类和函数插件通
| [`preset/`](preset/README.md) | 由 preset `cordis.yml` 按会话组装 agent | 产品:稳定接口 |
| [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 + `tools/execute` 截止时间强制执行器 | 产品:稳定接口 |
| [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定接口 |
| [`self-modification/`](self-modification/README.md) | agent 运行时自修改:实时插件/服务检查模型所写插件挂载/卸载([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)、受限仓库插件加载 | 产品:稳定接口 |
| [`self-modification/`](self-modification/README.md) | agent 运行时自修改:实时插件/服务检查模型所写插件挂载/卸载([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 产品:稳定接口 |
| [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude CodeCodex 协议格式库 | 产品:稳定接口 |
| [`session/`](session/README.md) | 持久会话数据平面:持久化 seam + JSONL/SQLite 后端、投影 seam、日志支持的标题、会话上报 | 产品:稳定接口 |
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定接口 |

View File

@@ -1,218 +0,0 @@
import { execFile } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { delimiter, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { promisify } from 'node:util'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { BUNDLED_PNPM_VERSION, RepositoryCache, type RepositoryInstall } from '@cordisjs/plugin-loader/repository'
const execFileAsync = promisify(execFile)
const roots: string[] = []
/** Normalize Git's platform checkout line endings for source-content assertions. */
const lf = (text: string): string => text.replace(/\r\n/g, '\n')
async function temporaryRoot(name: string): Promise<string> {
const root = await mkdtemp(join(tmpdir(), `cordis-${name}-`))
roots.push(root)
return root
}
async function fakePackage(directory: string): Promise<void> {
const target = join(directory, 'node_modules', 'repository')
await mkdir(target, { recursive: true })
await writeFile(join(target, 'package.json'), '{"name":"fixture"}\n')
}
afterEach(async () => {
vi.unstubAllEnvs()
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
})
describe('RepositoryCache', () => {
it('single-flights and permanently reuses an exact specifier', async () => {
const root = await temporaryRoot('repository-cache')
const calls: string[] = []
const install: RepositoryInstall = async (directory) => {
calls.push(directory)
await fakePackage(directory)
}
const cache = new RepositoryCache(root, { install })
const specifier = 'github:owner/repository#0123456789abcdef'
const [first, concurrent] = await Promise.all([cache.resolve(specifier), cache.resolve(specifier)])
expect(concurrent).toBe(first)
expect(calls).toHaveLength(1)
const reopened = new RepositoryCache(root, { install: async () => { throw new Error('cache miss') } })
expect(await reopened.resolve(specifier)).toBe(first)
expect(JSON.parse(await readFile(join(first, '..', '..', 'package.json'), 'utf8'))).toMatchObject({
packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`,
dependencies: { repository: specifier },
})
const second = await cache.resolve('github:owner/repository#fedcba9876543210')
expect(second).not.toBe(first)
expect(calls).toHaveLength(2)
})
it('accepts the valid winner when independent cache instances race', async () => {
const root = await temporaryRoot('repository-race')
const bothStarted = Promise.withResolvers<undefined>()
let starts = 0
const install: RepositoryInstall = async (directory) => {
await fakePackage(directory)
starts += 1
if (starts === 2) bothStarted.resolve(undefined)
await bothStarted.promise
}
const specifier = 'github:owner/repository#race'
const [first, second] = await Promise.all([
new RepositoryCache(root, { install }).resolve(specifier),
new RepositoryCache(root, { install }).resolve(specifier),
])
expect(second).toBe(first)
expect(starts).toBe(2)
expect(await readdir(root)).toHaveLength(1)
})
it('removes a failed staging tree and permits an exact retry', async () => {
const root = await temporaryRoot('repository-retry')
let attempts = 0
const cache = new RepositoryCache(root, { install: async (directory) => {
attempts += 1
if (attempts === 1) throw new Error('install failed')
await fakePackage(directory)
} })
await expect(cache.resolve('github:owner/repository#ref')).rejects.toThrow('failed to prepare repository')
expect(await readdir(root)).toEqual([])
await expect(cache.resolve('github:owner/repository#ref')).resolves.toContain('node_modules')
expect(attempts).toBe(2)
})
it('rejects empty or padded specifiers before touching the cache', async () => {
const root = await temporaryRoot('repository-input')
const cache = new RepositoryCache(root, { install: fakePackage })
expect(() => cache.resolve('')).toThrow('non-empty unpadded string')
expect(() => cache.resolve(' github:owner/repository#ref')).toThrow('non-empty unpadded string')
await expect(readdir(root)).resolves.toEqual([])
})
it('fails loud on a corrupt published marker instead of reinstalling it', async () => {
const root = await temporaryRoot('repository-corrupt')
const specifier = 'github:owner/repository#corrupt'
const key = createHash('sha256').update(specifier).digest('hex')
const entry = join(root, key)
await mkdir(join(entry, 'node_modules', 'repository'), { recursive: true })
await writeFile(join(entry, '.repository-cache.json'), '{}\n')
const cache = new RepositoryCache(root, { install: async () => { throw new Error('must not reinstall') } })
await expect(cache.resolve(specifier)).rejects.toThrow('repository cache marker is invalid')
})
it('isolates and prepares a .dsh-plugin Git subpath from an enclosing pnpm workspace', { timeout: 60_000 }, async () => {
const root = await temporaryRoot('repository-pnpm')
const repository = join(root, 'source')
await mkdir(join(repository, '.dsh-plugin'), { recursive: true })
await mkdir(join(repository, '.dsh-plugin', 'build-helper'), { recursive: true })
await mkdir(join(repository, '.dsh-plugin', 'prepare-helper'), { recursive: true })
await mkdir(join(repository, 'skills', 'fixture'), { recursive: true })
const shadowPnpm = join(root, 'shadow-pnpm')
await mkdir(shadowPnpm)
await writeFile(join(shadowPnpm, 'pnpm'), '#!/bin/sh\nexit 99\n', { mode: 0o700 })
await writeFile(join(shadowPnpm, 'pnpm.bat'), '@exit /b 99\r\n')
await writeFile(join(repository, 'package.json'), `${JSON.stringify({
name: 'repository-fixture',
private: true,
version: '1.0.0',
packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`,
})}\n`)
await writeFile(join(repository, 'pnpm-workspace.yaml'), 'packages: []\n')
await writeFile(join(repository, 'pnpm-lock.yaml'), [
"lockfileVersion: '9.0'",
'settings:',
' autoInstallPeers: true',
' excludeLinksFromLockfile: false',
'importers:',
' .: {}',
'',
].join('\n'))
await writeFile(join(repository, '.dsh-plugin', 'build-helper', 'package.json'), `${JSON.stringify({
name: 'repository-build-helper',
version: '1.0.0',
bin: 'index.js',
})}\n`)
await writeFile(join(repository, '.dsh-plugin', 'build-helper', 'index.js'), [
'#!/usr/bin/env node',
"require('node:fs').writeFileSync('dependency-built.txt', 'dependency available\\n')",
'',
].join('\n'), { mode: 0o700 })
await writeFile(join(repository, '.dsh-plugin', 'prepare-helper', 'package.json'), `${JSON.stringify({
name: 'repository-prepare-helper',
version: '1.0.0',
bin: { 'dsh-plugin-prepare': 'index.js' },
})}\n`)
await writeFile(join(repository, '.dsh-plugin', 'prepare-helper', 'index.js'), [
'#!/usr/bin/env node',
"const { cpSync, mkdirSync, writeFileSync } = require('node:fs')",
"mkdirSync('dsh-plugin-assets/skills', { recursive: true })",
"cpSync('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })",
"writeFileSync('dsh-plugin.mjs', 'export function apply() {}\\n')",
"writeFileSync('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}|${process.env.PNPM_CONFIG_IGNORE_WORKSPACE ?? 'absent'}\\n`)",
"writeFileSync('environment.json', `${JSON.stringify({ path: process.env.PATH, pathExt: process.env.PATHEXT })}\\n`)",
'',
].join('\n'), { mode: 0o700 })
await writeFile(join(repository, 'skills', 'fixture', 'SKILL.md'), 'repository skill source\n')
await writeFile(join(repository, '.dsh-plugin', 'package.json'), `${JSON.stringify({
name: 'repository-plugin-fixture',
version: '1.0.0',
scripts: {
// The fixture owns dependency installation, not platform-specific
// node_modules/.bin shim generation during pnpm's Git preparation.
prepack: [
'node ./node_modules/repository-build-helper/index.js',
'node ./node_modules/repository-prepare-helper/index.js',
].join(' && '),
},
devDependencies: {
'repository-build-helper': 'file:./build-helper',
'repository-prepare-helper': 'file:./prepare-helper',
},
dsh: { skills: ['../skills'] },
})}\n`)
await execFileAsync('git', ['init', '--quiet'], { cwd: repository })
await execFileAsync('git', ['add', '.'], { cwd: repository })
await execFileAsync('git', [
'-c', 'user.name=Repository Fixture',
'-c', 'user.email=repository@example.invalid',
'commit', '--quiet', '-m', 'fixture',
], { cwd: repository })
const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repository, encoding: 'utf8' })
const specifier = `git+${pathToFileURL(repository).href}#${stdout.trim()}&path:/.dsh-plugin`
vi.stubEnv('REPOSITORY_TEST_VISIBLE', 'visible')
vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden')
vi.stubEnv('PNPM_HOME', shadowPnpm)
vi.stubEnv('PATH', [shadowPnpm, ...(process.env.PATH === undefined ? [] : [process.env.PATH])].join(delimiter))
vi.stubEnv('PATHEXT', '.BAT;.CMD;.EXE')
const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier)
await expect(readFile(join(installed, 'dependency-built.txt'), 'utf8')).resolves.toBe('dependency available\n')
await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent|true\n')
const environment = JSON.parse(await readFile(join(installed, 'environment.json'), 'utf8')) as {
path: string
pathExt: string
}
expect(environment.path.split(delimiter)).not.toContain(shadowPnpm)
expect(environment.pathExt.split(';')[0]?.toUpperCase()).toBe('.CMD')
await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply')
expect(lf(await readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8')))
.toBe('repository skill source\n')
await expect(readFile(join(installed, 'package.json'), 'utf8'))
.resolves.toContain('repository-plugin-fixture')
})
})

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/bundle/base/README.md
README.md: 2a87b01ad4819750a58163f8c472e61ea633588e
README.zh.md: dc79895355546812aa3371487190724f169c6260
README.md: 70ecc181da8f0c120b8da0d55f68d47bf22d5820
README.zh.md: 11f10bf561429c11471ff57d08950677e4924b40

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code.
The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, and telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code.
Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only bash stack (`bash-sandbox`/`tool-bash`) and inserts the sandbox-confined PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox`, `@deepseek-ai/dsh-tool-pwsh`). The permission surface stays exactly as on POSIX: `sandbox`/`sandbox-policy` enforce the file-effect policy through the Windows ACL restricted-token runner (the win32 chain of `dsh-sandbox-local``@deepseek-ai/dsh-sandbox-windows-acl`), the permission switcher and the approval service run unchanged, and `fs-sandbox` keeps fencing `ctx.fs` writes — mounting `dsh-fs-local` alongside it would double-register `ctx.fs` and fail the load. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the unconfined local pwsh executor or full access overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts never receive it.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.md) 选择、工具、持久化、策略、settingscredentials、repository 插件、遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 APIprofile 组合器通过 manifest元数据清单`dsh.bundle.patch` 字段解析 patch绝不通过代码。
以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.md) 选择、工具、持久化、策略、settingscredentials遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 APIprofile 组合器通过 manifest元数据清单`dsh.bundle.patch` 字段解析 patch绝不通过代码。
启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的 bash 栈(`bash-sandbox`/`tool-bash`),并插入沙盒受限的 PowerShell 栈(`@deepseek-ai/dsh-pwsh-sandbox``@deepseek-ai/dsh-tool-pwsh`)。权限面与 POSIX 完全一致:`sandbox`/`sandbox-policy` 通过 Windows ACL 受限令牌 runner`dsh-sandbox-local` 的 win32 链 → `@deepseek-ai/dsh-sandbox-windows-acl`)执行文件效果策略,权限切换器与 approval 服务原样运行,`fs-sandbox` 继续围栏 `ctx.fs` 写入——在其旁再挂载 `dsh-fs-local` 会重复注册 `ctx.fs` 并在加载时失败。启动器在 win32 主机上把该层应用于 bundle 层与用户层之间;偏好不限权本地 pwsh 执行器或完整访问的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时 fail loud。POSIX 主机永远不会收到它。

View File

@@ -21,13 +21,6 @@
config:
root: ['.']
# The profile's cordis.patch.yml replaces this row's config to select exact GitHub
# repository Plugin generations. The app registers the DSH-owned runtime even
# when the list is empty so a later personal-config edit can load
# transactionally; one-shot headless runs consume the startup value only.
- id: repository-plugins
name: '@deepseek-ai/dsh-repository-plugin'
- id: llm
name: '@deepseek-ai/dsh-llm'

View File

@@ -63,7 +63,6 @@
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-pwsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-repeat-tool-guard": "workspace:^",
"@deepseek-ai/dsh-repository-plugin": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",

View File

@@ -33,14 +33,8 @@ export const inject = ['tools']
/** Default timeout for individual MCP tool calls (ms). */
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.
*/
export const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/
/** Valid `serverName`, kept below the public tool-name budget. */
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/self-modification/README.md
README.md: c94f9ac9a79709e418448d9e440c38296e759335
README.zh.md: 44b5d9ff4cb09fb4a4e44f446894c9925a62ec51
README.md: 2f409f779ae32c9eedc9c57dbb6476c0639205ca
README.zh.md: 09c34c18b66803c544d7a572820c2001405fb6dd

View File

@@ -2,9 +2,8 @@
English | [中文](README.zh.md)
Model-facing tools over the live cordis runtime the agent itself runs inside: inspect the loaded plugins and service surface, mount model-written plugins, and dispose them again — plus the restricted repository Plugin runtime. Design home: [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
Model-facing tools over the live cordis runtime the agent itself runs inside: inspect the loaded plugins and service surface, mount model-written plugins, and dispose them again. The group is the landing zone for future self-modification packages. Design home: [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
| Package | Role | ctx key |
|---|---|---|
| [`tool-cordis/`](tool-cordis/README.md) | Model-facing runtime inspection and temporary-plugin tools | registers on `ctx.tools` |
| [`repository-plugin/`](repository-plugin/README.md) | Repository skill and MCP composition | registers a Loader builtin |

View File

@@ -2,9 +2,8 @@
[English](README.md) | 中文
agent 修改自身运行时:检查已加载的插件与服务接口、挂载模型编写的插件并再次 dispose,外加受限 repository Plugin 运行时。设计居所:[工具集 Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
agent 修改自身运行时:检查已加载的插件与服务接口、挂载模型编写的插件并再次 dispose。该组是未来自我修改类包的落点。设计居所:[工具集 Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
| 包package | 角色 | ctx 键 |
|---|---|---|
| [`tool-cordis/`](tool-cordis/README.md) | `cordis_inspect``cordis_mount``cordis_unmount` 工具:读取当前进程运行时,并在一个自有分组 fiber 下管理内存中的临时插件 | 注册到 `ctx.tools` |
| [`repository-plugin/`](repository-plugin/README.md) | 通过 DSH 自有子 Plugin 准备并挂载静态 repository skills 与通用 `.mcp.json` server | 注册一个 Loader builtin |

View File

@@ -1,6 +0,0 @@
# 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: 666f00e02b9ab33bff348df6b4ff90e3f3bfecc7
README.zh.md: b09f68bc17a4eb08df6ecbb3782e14bf26fb7d7f

View File

@@ -1,128 +0,0 @@
# @deepseek-ai/dsh-repository-plugin
English | [中文](README.zh.md)
Trusted repository package format for DeepSeek Harness. A `.dsh-plugin` npm package may contribute a compiled Cordis/DSH Plugin entry, skill roots, and a common `.mcp.json`; its ordinary `prepack` lifecycle owns dependency installation and source compilation before the DSH prepare helper validates the outputs and emits the Loader wrapper. Static contributions compose [`dsh-skill-local`](../../skill/skill-local/README.md) and [`dsh-mcp-client`](../../mcp/mcp-client/README.md). Design rationale: [trusted repository package code](../../../.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md) and the [static contribution subformat](../../../.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,
"type": "module",
"scripts": {
"build": "tsc",
"prepack": "npm run build && dsh-plugin-prepare"
},
"dsh": {
"entry": "./lib/plugin.js",
"skills": ["../skills"],
"mcpServers": "../.mcp.json"
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.29.0"
},
"devDependencies": {
"@deepseek-ai/dsh-repository-plugin": "^0.0.1",
"typescript": "6.0.3"
}
}
```
`scripts.prepack` must be non-empty and invoke `dsh-plugin-prepare`; it may run arbitrary package-owned build steps first. The package declares `@deepseek-ai/dsh-repository-plugin` as an ordinary development dependency so its published executable is available to that lifecycle. DSH does not inject the helper: the repository package declares and runs its own compiler, runtime dependencies, preparation helper, and other npm lifecycle code. The selected package is installed from its own manifest instead of inheriting an enclosing pnpm workspace, so declare every dependency it needs and do not depend on workspace-only hoisting. DSH does not transpile TypeScript or infer a package entry.
`dsh.entry` is an optional relative path to a compiled ESM Cordis Plugin inside `.dsh-plugin`. The module may use either namespace exports or a default export and owns its ordinary `name`, `inject`, `Config`, registrations, and effects. `dsh.skills` is an optional array of local skill roots, and `dsh.mcpServers` is an optional path to one `.mcp.json`; at least one of the three fields is required. Skill and MCP paths may reach adjacent repository assets but must remain beneath the directory containing `.dsh-plugin`; the compiled entry must remain inside the package selected and packed by the package manager. A repository containing several Plugins gives each one its own `.dsh-plugin` package under a different selectable subdirectory.
The repository package and every dependency or lifecycle script it runs are trusted code, just like an npm package selected directly by the user. This format is not a sandbox: install only repositories whose code may access the host process, filesystem, network, and services declared through Cordis. Exact refs and the immutable cache provide identity and reproducibility, not isolation.
## 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.
Git transport uses the host's ordinary Git authentication. Public repositories need no credentials; private sources require a read-only credential or SSH agent that can read the selected repository. DSH removes credential-shaped environment variables before package lifecycles, so configure Git itself, such as through a credential helper or job-scoped Git config, instead of expecting an exported token variable to cross that boundary. Repository lifecycle code is trusted and can invoke Git, so use the narrowest repository-scoped credential available.
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
During exact Git installation, DSH's bundled pnpm installs the selected package from its own manifest. A transaction-owned `pnpm` wrapper reinvokes the same pinned pnpm with `--ignore-workspace`, so an enclosing workspace lockfile cannot suppress dependencies declared only by the selected `.dsh-plugin` package. The required `prepack` lifecycle runs after that dependency installation and before the selected subdirectory is packed; its ordinary `node_modules/.bin` lookup obtains `dsh-plugin-prepare` from the declared direct development dependency on `@deepseek-ai/dsh-repository-plugin`. That package marks its Cordis/DSH runtime peers optional so using the executable alone does not install the runtime graph. Package-owned commands may build TypeScript or other source before invoking the helper. The helper validates `package.json#dsh`, verifies that the compiled entry is an in-package file, validates skill and MCP sources, copies static assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. Before importing that wrapper, DSH revalidates that the installed package retained both the direct development dependency and a `prepack` declaration containing the helper command. Failure to resolve the published helper, install dependencies, build, or prepare fails before a cache generation is published. Rationale: [npm-backed Git source preparation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md).
## 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 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 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
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.
### Repository code
#### What the model sees
Data-dependent. The trusted Cordis entry may contribute any DSH behavior available through its declared services and events, including tools, prompt sections, policies, commands, and transformations. Every model-visible contribution remains subject to its owning DSH seam's logging and lifecycle contract.
#### Token effect
Defined by the services and registrations the entry contributes; the repository format itself adds no model content.
#### KV Cache effect
Stable registrations preserve the owning surface's normal prefix behavior. Loading, removing, or replacing the exact repository generation can change any prefixes affected by that Plugin.
## 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

@@ -1,128 +0,0 @@
# @deepseek-ai/dsh-repository-plugin
[English](README.md) | 中文
这是 DeepSeek Harness 的受信任 repository 包格式。`.dsh-plugin` NPM 包可以贡献已编译的 CordisDSH 插件入口、skill技能根和通用 `.mcp.json`;其常规 `prepack` 生命周期负责安装依赖并编译源码,随后 DSH 准备辅助程序校验输出并生成 Loader 包装层。静态贡献由 [`dsh-skill-local`](../../skill/skill-local/README.md) 与 [`dsh-mcp-client`](../../mcp/mcp-client/README.md) 组合。设计依据见[受信任 repository 包代码](../../../.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md)和[静态贡献子格式](../../../.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,
"type": "module",
"scripts": {
"build": "tsc",
"prepack": "npm run build && dsh-plugin-prepare"
},
"dsh": {
"entry": "./lib/plugin.js",
"skills": ["../skills"],
"mcpServers": "../.mcp.json"
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.29.0"
},
"devDependencies": {
"@deepseek-ai/dsh-repository-plugin": "^0.0.1",
"typescript": "6.0.3"
}
}
```
`scripts.prepack` 必须非空并调用 `dsh-plugin-prepare`;可以先运行任意包自有的构建步骤。包将 `@deepseek-ai/dsh-repository-plugin` 声明为普通开发依赖使该生命周期可以使用其已发布的可执行文件。DSH 不会注入辅助程序repository 包自行声明并运行编译器、运行时依赖、准备辅助程序及其他 NPM 生命周期代码。所选包按自身 manifest 独立安装,而不继承外层 pnpm workspace因此必须声明所需的每项依赖不能依赖仅由 workspace 提升而可见的包。DSH 不转译 TypeScript也不推断包入口。
`dsh.entry` 是指向 `.dsh-plugin` 内已编译 ESM Cordis 插件的可选相对路径。该模块可以使用 namespace 导出或 default export并自行拥有常规的 `name``inject``Config`、注册和 effect。`dsh.skills` 是可选的本地 skill 根数组,`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径三个字段中至少声明一个。skill 和 MCP 路径可以引用相邻的 repository 资源,但必须留在包含 `.dsh-plugin` 的目录下;已编译入口必须留在由包管理器选中并打包的包内。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` 包。
repository 包及其运行的每项依赖或生命周期脚本都是受信任代码,与用户直接选择的 NPM 包相同。本格式不是沙箱:只有在你信任仓库代码并愿意允许其访问宿主进程、文件系统、网络及其通过 Cordis 声明的服务时才应安装。精确 ref 和不可变缓存提供身份与可复现性,而非隔离。
## 独立应用配置
随附的 `dsh-base` 组合包是每个 profile 的起点,其中包含一个空 `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`
Git 传输使用宿主的常规 Git 认证。公共仓库无需凭据;私有源需要可读取所选仓库的只读凭据或 SSH agent。DSH 会在包生命周期运行前移除名称符合凭据模式的环境变量,因此请配置 Git 本身,例如使用 Git 凭据辅助工具或作业作用域的 Git 配置,而不要指望已导出的 token 变量跨越该边界。仓库生命周期代码受信任且可以调用 Git因此请使用作用域最窄且仅限所选仓库的凭据。
长期运行的 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)。
## 准备阶段
安装精确指定的 Git 源时DSH 随附的 pnpm 会按所选包自身的 manifest 安装。由事务持有的 `pnpm` 包装脚本会以 `--ignore-workspace` 重新调用同一份锁定的 pnpm因此外层 workspace lockfile 无法抑制仅由所选 `.dsh-plugin` 包声明的依赖。必需的 `prepack` 生命周期在该依赖安装完成后、选定子目录打包前运行;其常规 `node_modules/.bin` 查找会从直接声明的 `@deepseek-ai/dsh-repository-plugin` 开发依赖中取得 `dsh-plugin-prepare`。该包把 CordisDSH 运行时对等依赖peer dependency标为可选因此单独使用该可执行文件不会安装运行时依赖图。包自有命令可以在调用辅助程序前构建 TypeScript 或其他源码。辅助程序会校验 `package.json#dsh`,确认已编译入口是包内文件,校验 skill 与 MCP 源,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。导入该包装层前DSH 会重新校验已安装包是否仍同时保留该直接开发依赖,以及包含该辅助命令的 `prepack` 声明。无法解析已发布的辅助程序,或安装依赖、构建或准备失败时,流程会在发布缓存 generation 前失败。设计依据见[基于 NPM 的 Git 源准备 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md)。
## 运行时组合
加载本包会注册一个 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而不是在缺少已声明工具的情况下静默激活。
## 导出形状
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 前缀。
### Repository 代码
#### 模型看到什么
取决于数据。受信任的 Cordis 入口可以通过其声明的服务和事件贡献任意可用的 DSH 行为,包括工具、提示词片段、策略、命令和转换。每项模型可见贡献仍受所属 DSH seam 的日志与生命周期约定约束。
#### Token 影响
由入口贡献的服务和注册决定repository 格式本身不添加模型内容。
#### KV Cache 影响
稳定的注册会保留所属表面的正常前缀行为。加载、移除或替换精确的 repository generation可能改变受该插件影响的任意前缀。
## 已知限制与暂缓事项
- **没有代码沙箱**`dsh.entry`、NPM 依赖和包生命周期脚本以 DSH 宿主权限执行;必须信任该 repository。
- **入口专用服务依赖不会预先门控**:生成的包装层无法在导入入口模块前声明其 `inject`。除 skill 或 MCP 隐含的服务外,其他任何服务在包装层挂载入口时都必须已经存在,否则该 repository generation 会被拒绝。
- **没有 MCP 认证协议**:静态 header 可以使用环境变量展开,但带 OAuth 的定义会被拒绝,私有 server 登录流程不在此实现。
- **生成资源是不可变运行时输入**repository cache generation 不受监视;必须改变 source、ref、path 或配置才能选择另一份已准备 generation。

View File

@@ -1,73 +0,0 @@
{
"name": "@deepseek-ai/dsh-repository-plugin",
"description": "Trusted repository package 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"
},
"peerDependenciesMeta": {
"@cordisjs/plugin-loader": {
"optional": true
},
"@deepseek-ai/dsh-invariants": {
"optional": true
},
"@deepseek-ai/dsh-mcp-client": {
"optional": true
},
"@deepseek-ai/dsh-paths": {
"optional": true
},
"@deepseek-ai/dsh-skill-local": {
"optional": true
},
"cordis": {
"optional": true
}
},
"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"
}
}

View File

@@ -1,12 +0,0 @@
#!/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
}

View File

@@ -1,249 +0,0 @@
/**
* Trusted repository-package 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 repository wrapper. */
export const REPOSITORY_PLUGIN_BUILTIN = 'dsh-repository-plugin'
/** Dependency-provided command that repository package `prepack` lifecycles must invoke. */
export const REPOSITORY_PLUGIN_PREPARE_COMMAND = 'dsh-plugin-prepare'
/** Published package whose direct development dependency supplies the prepare command. */
export const REPOSITORY_PLUGIN_PACKAGE_NAME = '@deepseek-ai/dsh-repository-plugin'
/**
* Whether a package lifecycle declaration names the preparation dependency's helper.
* @param script - package-authored lifecycle command.
* @returns true when the required helper command is present.
*/
export function hasRepositoryPrepareCommand(script: string): boolean {
return script.includes(REPOSITORY_PLUGIN_PREPARE_COMMAND)
}
const prepackSchema = z.string().min(1).refine(
hasRepositoryPrepareCommand,
{ message: `must invoke ${REPOSITORY_PLUGIN_PREPARE_COMMAND}` },
)
const sourceMetadataSchema = z.object({
skills: z.array(z.string().min(1)).default([]),
mcpServers: z.string().min(1).optional(),
entry: z.string().min(1).optional(),
}).strict().refine(value => value.skills.length > 0 || value.mcpServers !== undefined || value.entry !== undefined, {
message: 'declare at least one skill root, mcpServers file, or compiled entry',
})
const sourcePackageSchema = z.looseObject({
name: z.string().min(1),
devDependencies: z.looseObject({
[REPOSITORY_PLUGIN_PACKAGE_NAME]: z.string().min(1),
}),
scripts: z.looseObject({
prepack: prepackSchema,
}),
dsh: sourceMetadataSchema,
})
const preparedManifestSchema = z.object({
name: z.string().min(1),
skills: z.array(z.string().min(1)),
mcpServers: z.string().min(1).optional(),
entry: 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()
/** Prepared manifest embedded in the generated wrapper. */
export interface PreparedPluginManifest {
name: string
skills: string[]
mcpServers?: string
entry?: 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 },
...result.data.manifest.entry === undefined ? {} : { entry: result.data.manifest.entry },
},
}
}
/**
* 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'],
]
const entryHelpers = manifest.entry === undefined ? [] : [
'function unwrap(exports) {',
' const value = exports?.default ?? exports',
' return value?.__esModule ? (value.default ?? value) : value',
'}',
]
const entryApply = manifest.entry === undefined ? [] : [
' const repositoryPlugin = unwrap(await import(manifest.entry))',
" await mount(ctx, repositoryPlugin, 'repository Plugin entry')",
]
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)}`,
...entryHelpers,
'async function mount(ctx, plugin, label, config) {',
' const fiber = ctx.plugin(plugin, config)',
' await fiber',
' if (fiber.state !== FIBER_ACTIVE) {',
' const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)',
" throw new Error(`${label} did not activate (waiting for services: ${missing.join(', ') || 'unknown'})`)",
' }',
'}',
'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 mount(ctx, runtime, 'repository Plugin runtime', { baseUrl: import.meta.url, manifest })",
...entryApply,
'}',
'',
].join('\n')
}
/**
* Validate and package one `.dsh-plugin` directory into copied assets plus a generated 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 prepared 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 DSH plugin package.json', 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'))
}
let entry: string | undefined
if (parsed.data.dsh.entry !== undefined) {
const entrySource = await sourcePath(pluginDirectory, pluginDirectory, parsed.data.dsh.entry, 'file')
entry = `./${relative(pluginDirectory, entrySource).split(sep).join('/')}`
}
const manifest: PreparedPluginManifest = {
name: parsed.data.name,
skills: skillSources.map((_, index) => `${PREPARED_ASSET_DIRECTORY}/skills/${index}`),
...mcpSource === undefined ? {} : { mcpServers: `${PREPARED_ASSET_DIRECTORY}/.mcp.json` },
...entry === undefined ? {} : { entry },
}
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
}

View File

@@ -1,147 +0,0 @@
/**
* Trusted repository-package runtime for code, 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,
REPOSITORY_PLUGIN_PACKAGE_NAME,
REPOSITORY_PLUGIN_PREPARE_COMMAND,
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 matches 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')
}

View File

@@ -1,30 +0,0 @@
/**
* 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 */

View File

@@ -1,156 +0,0 @@
/**
* 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 service, 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
failOnStartupError: true
}
| {
transport: 'streamable-http'
serverName: string
url: string
headers: Record<string, string>
failOnStartupError: true
}
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,
failOnStartupError: true,
}
}
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`),
failOnStartupError: true,
}
})
}

View File

@@ -1,130 +0,0 @@
/**
* GitHub repository source validation and prepared-wrapper loading.
* @module
*/
import { readFile } from 'node:fs/promises'
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 { z } from 'zod'
import {
PREPARED_ENTRY_FILENAME,
REPOSITORY_PLUGIN_PACKAGE_NAME,
REPOSITORY_PLUGIN_PREPARE_COMMAND,
hasRepositoryPrepareCommand,
} 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&]+))?$/
const installedPackageSchema = z.looseObject({
devDependencies: z.looseObject({
[REPOSITORY_PLUGIN_PACKAGE_NAME]: z.string().min(1),
}),
scripts: z.looseObject({
prepack: z.string().min(1).refine(
hasRepositoryPrepareCommand,
{ message: `must invoke ${REPOSITORY_PLUGIN_PREPARE_COMMAND}` },
),
}),
})
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))
}
async function assertInstalledPackageMetadata(directory: string): Promise<void> {
let value: unknown
try {
value = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) as unknown
} catch (cause) {
throw new Error(`failed to read installed DSH plugin package metadata in ${directory}`, { cause })
}
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)}, and declare ${JSON.stringify(REPOSITORY_PLUGIN_PACKAGE_NAME)} in devDependencies:`,
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'))
}
}
/**
* 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 {
await assertInstalledPackageMetadata(directory)
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 })
}
}

View File

@@ -1,121 +0,0 @@
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: {},
failOnStartupError: true,
}])
})
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',
failOnStartupError: true,
}])
})
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',
failOnStartupError: true,
},
{
transport: 'streamable-http',
serverName: 'remote',
url: 'http://localhost:3000/mcp',
headers: { Authorization: 'Bearer test-token' },
failOnStartupError: true,
},
])
})
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')
})
})

View File

@@ -1,676 +0,0 @@
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>,
prepack = RepositoryPlugin.REPOSITORY_PLUGIN_PREPARE_COMMAND,
devDependencies: Record<string, string> = {
[RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME]: '0.0.1',
},
): 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',
devDependencies,
scripts: { prepack },
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('preserves a compiled package entry and accepts a build before the package prepare command', async () => {
const root = await temporaryDirectory('compiled-entry')
const directory = await writePlugin(root, 'compiled-entry-fixture', {
entry: './lib/plugin.mjs',
}, 'npm run build && dsh-plugin-prepare')
await mkdir(join(directory, 'lib'))
await writeFile(join(directory, 'lib/plugin.mjs'), 'export default { name: "compiled-entry" }\n')
await expect(RepositoryPlugin.prepareDshPlugin(directory)).resolves.toEqual({
name: 'compiled-entry-fixture',
skills: [],
entry: './lib/plugin.mjs',
})
const wrapper = await readFile(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME), 'utf8')
expect(wrapper).toContain('await import(manifest.entry)')
expect(wrapper).toContain('"entry":"./lib/plugin.mjs"')
})
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 lifecycleRoot = await temporaryDirectory('wrong-lifecycle')
const lifecycle = join(lifecycleRoot, '.dsh-plugin')
await mkdir(lifecycle)
await writeFile(join(lifecycle, 'package.json'), JSON.stringify({
name: 'wrong-lifecycle',
scripts: { prepare: 'dsh-plugin-prepare' },
dsh: { skills: ['../skills'] },
}))
await expect(RepositoryPlugin.prepareDshPlugin(lifecycle)).rejects.toThrow('prepack')
const skippedPrepareRoot = await temporaryDirectory('skipped-prepare')
const skippedPrepare = await writePlugin(
skippedPrepareRoot,
'skipped-prepare',
{ skills: ['../skills'] },
'npm run build',
)
await expect(RepositoryPlugin.prepareDshPlugin(skippedPrepare)).rejects.toThrow('must invoke dsh-plugin-prepare')
const undeclaredPrepareRoot = await temporaryDirectory('undeclared-prepare-dependency')
const undeclaredPrepare = await writePlugin(
undeclaredPrepareRoot,
'undeclared-prepare-dependency',
{ skills: ['../skills'] },
RepositoryPlugin.REPOSITORY_PLUGIN_PREPARE_COMMAND,
{},
)
await expect(RepositoryPlugin.prepareDshPlugin(undeclaredPrepare))
.rejects.toThrow(RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME)
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, mcpServers file, or compiled entry')
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')
const escapedEntryRoot = await temporaryDirectory('escaped-entry')
await writeFile(join(escapedEntryRoot, 'outside.mjs'), 'export default {}\n')
const escapedEntry = await writePlugin(escapedEntryRoot, 'escaped-entry', { entry: '../outside.mjs' })
await expect(RepositoryPlugin.prepareDshPlugin(escapedEntry)).rejects.toThrow('escapes its plugin source root')
})
it('validates prepared wrapper configs with optional MCP assets and code entries', () => {
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', entry: './lib/plugin.js' },
})).toEqual({
baseUrl: 'file:///plugin/dsh-plugin.mjs',
manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json', entry: './lib/plugin.js' },
})
})
})
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('mounts and removes the repository package code entry through the real Loader', async () => {
const root = await temporaryDirectory('code-loader')
const directory = await writePlugin(root, 'code-loader-fixture', { entry: './lib/plugin.mjs' })
await mkdir(join(directory, 'lib'))
await writeFile(join(directory, 'lib/plugin.mjs'), [
"export const name = 'repository-code-proof'",
'export function apply(ctx) {',
" ctx.provide('repositoryCodeProof', { source: 'compiled-entry' })",
'}',
'',
].join('\n'))
await RepositoryPlugin.prepareDshPlugin(directory)
const ctx = new Context()
ctx.baseUrl = pathToFileURL(directory).href + '/'
await ctx.plugin(Loader)
await ctx.plugin(RepositoryPlugin)
const id = await ctx.loader.create({
name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href,
})
await ctx.loader.await()
const getService = (name: string): unknown => (ctx as unknown as { get(name: string): unknown }).get(name)
expect(getService('repositoryCodeProof')).toEqual({ source: 'compiled-entry' })
await ctx.loader.remove(id)
expect(getService('repositoryCodeProof')).toBeUndefined()
await ctx.fiber.dispose()
})
it('mounts and removes tools discovered from a repository MCP server', async () => {
const root = await temporaryDirectory('mcp-loader-success')
const server = join(root, 'mcp-server.mjs')
await writeFile(server, [
"import { createInterface } from 'node:readline'",
'const lines = createInterface({ input: process.stdin })',
'for await (const line of lines) {',
' const request = JSON.parse(line)',
" if (!('id' in request)) continue",
' let result',
" if (request.method === 'initialize') {",
' result = {',
' protocolVersion: request.params.protocolVersion,',
' capabilities: { tools: {} },',
" serverInfo: { name: 'repository-fixture', version: '0.0.0' },",
' }',
" } else if (request.method === 'tools/list') {",
' result = {',
' tools: [{',
" name: 'proof',",
" description: 'Repository MCP proof.',",
" inputSchema: { type: 'object', properties: {} },",
' }],',
' }',
' } else {',
' result = {}',
' }',
" process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, result })}\\n`)",
'}',
'',
].join('\n'))
await writeFile(join(root, '.mcp.json'), JSON.stringify({
mcpServers: { online: { command: process.execPath, args: [server] } },
}))
const directory = await writePlugin(root, 'mcp-loader-success-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.get('mcp__online__proof')).toBeDefined()
await ctx.loader.remove(id)
expect(ctx.tools.get('mcp__online__proof')).toBeUndefined()
await ctx.fiber.dispose()
})
it('fails an MCP repository plugin load when its declared server cannot connect', 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)
await expect(ctx.loader.create({
name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href,
})).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()
})
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 directory = await writePlugin(root, 'missing-wrapper', { skills: ['../skills'] })
const ctx = new Context()
const specifier = 'github:owner/repository#missing&path:/.dsh-plugin'
await expect(loadPreparedRepository(ctx, { resolve: async () => directory }, specifier))
.rejects.toThrow(`failed to load prepared repository Plugin ${JSON.stringify(specifier)}`)
await ctx.fiber.dispose()
})
it('rejects installed source with the obsolete prepare lifecycle', async () => {
const root = await temporaryDirectory('installed-lifecycle')
await writeFile(join(root, 'package.json'), JSON.stringify({
name: 'installed-lifecycle',
devDependencies: { [RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME]: '0.0.1' },
scripts: { prepare: 'dsh-plugin-prepare' },
}))
const ctx = new Context()
await expect(loadPreparedRepository(ctx, { resolve: async () => root }, 'github:owner/repository#old&path:/.dsh-plugin'))
.rejects.toMatchObject({
cause: expect.objectContaining({
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()
})
it('rejects an installed source whose prepack omits the package prepare command', async () => {
const root = await temporaryDirectory('installed-skipped-prepare')
await writeFile(join(root, 'package.json'), JSON.stringify({
name: 'installed-skipped-prepare',
devDependencies: { [RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME]: '0.0.1' },
scripts: { prepack: 'npm run build' },
}))
const ctx = new Context()
await expect(loadPreparedRepository(ctx, { resolve: async () => root }, 'github:owner/repository#unprepared&path:/.dsh-plugin'))
.rejects.toMatchObject({
cause: expect.objectContaining({
message: expect.stringContaining('must invoke dsh-plugin-prepare') as string,
}) as Error,
})
await ctx.fiber.dispose()
})
it('rejects installed source without the declared prepare dependency', async () => {
const root = await temporaryDirectory('installed-missing-prepare-dependency')
await writeFile(join(root, 'package.json'), JSON.stringify({
name: 'installed-missing-prepare-dependency',
scripts: { prepack: 'dsh-plugin-prepare' },
}))
const ctx = new Context()
await expect(loadPreparedRepository(ctx, { resolve: async () => root }, 'github:owner/repository#ambient-helper&path:/.dsh-plugin'))
.rejects.toMatchObject({
cause: expect.objectContaining({
message: expect.stringContaining(`${JSON.stringify(RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME)} in devDependencies`) as string,
}) as Error,
})
await ctx.fiber.dispose()
})
it('labels missing installed package metadata with its source', async () => {
const root = await temporaryDirectory('missing-installed-metadata')
const ctx = new Context()
const specifier = 'github:owner/repository#damaged&path:/.dsh-plugin'
await expect(loadPreparedRepository(ctx, { resolve: async () => root }, specifier))
.rejects.toMatchObject({
message: expect.stringContaining(JSON.stringify(specifier)) as string,
cause: expect.objectContaining({
message: expect.stringContaining('failed to read installed DSH plugin package metadata') as string,
}) as Error,
})
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()
})
})

View File

@@ -1,33 +0,0 @@
{
"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"
}
]
}

View File

@@ -1,17 +0,0 @@
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,
},
])

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/tool-cordis/README.md
README.md: f2a65043a1d2f74553e98caf59ed3d38b5a70b7c
README.zh.md: 66742094992d219ccfbd60b935dcd10e48cb12b8
README.md: 4f856523cca4800cdbb98951183fbea1e3c96c87
README.zh.md: 7bb21396452ddbe49cf3008cd89cd6044b3c4a51

View File

@@ -14,7 +14,7 @@ Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-cata
Canonical successes are the inspection string, mount `{ id, pluginName, state, provides, waitingFor }`, and unmount `{ id, pluginName }`. Native rendering says whether the temporary Plugin is running or pending and that it remains available until unmounted or DSH restarts; unmount confirms that it was removed.
Temporary Plugins live only in the shared DSH process memory. They remain active across later turns and may affect other sessions in that process, but disappear after `cordis_unmount`, toolset unload, or DSH restart. They create no Plugin file, install no package, change no `cordis.yml` or personal/project configuration, do not survive restart, and cannot be promoted automatically. To keep an experiment, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow.
Temporary Plugins live only in the shared DSH process memory. They remain active across later turns and may affect other sessions in that process, but disappear after `cordis_unmount`, toolset unload, or DSH restart. They create no Plugin file, install no package, change no `cordis.yml` or personal/project configuration, do not survive restart, and cannot be promoted automatically. To keep an experiment, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow.
## Trust stance

View File

@@ -14,7 +14,7 @@
规范成功结果分别为检查字符串、挂载 `{ id, pluginName, state, provides, waitingFor }`,以及卸载 `{ id, pluginName }`。原生渲染会说明临时插件正在运行还是等待中,并说明它可用至被卸载或 DSH 重启;卸载结果确认它已移除。
临时插件只存在于共享 DSH 进程内存中。它可跨后续轮次保持活跃,也可能影响同一进程中的其他会话,但会在 `cordis_unmount`、工具集卸载或 DSH 重启后消失。它不会创建插件文件、安装任何包、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不能自动转为正式插件。若要保留实验结果,应让 agent智能体通过常规开发流程实现普通的本地、项目或仓库插件
临时插件只存在于共享 DSH 进程内存中。它可跨后续轮次保持活跃,也可能影响同一进程中的其他会话,但会在 `cordis_unmount`、工具集卸载或 DSH 重启后消失。它不会创建插件文件、安装任何包、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不能自动转为正式插件。若要保留实验结果,应让 agent智能体通过常规开发流程实现 SDK 插件或可安装的 profile 组合包
## 信任立场

View File

@@ -112,7 +112,7 @@ export function apply(ctx: Context, config: Config): void {
+ 'This creates an in-memory runtime Plugin, not an installed or configured Plugin. '
+ 'It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. '
+ 'It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. '
+ 'To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. '
+ 'To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. '
+ 'It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. '
+ '`code` runs now as the body of an async JavaScript function '
+ 'in an isolated sandbox and MUST `return` a plugin. Two forms: '

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: aa25278750b5a1577eb567e50344fb3af425d71a
README.zh.md: 59abd5623da189d0b5d739eec56e034b553690b9
README.md: 877b784353998a191a2d1a377aaf5406e1a97965
README.zh.md: e6f07b2f630ecd1e6261f32c6e58254c14bb450c

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

View File

@@ -166,9 +166,8 @@ export class LocalSkillProvider implements SkillProvider {
this.watchManager = new SkillWatchManager(ctx, control.invalidate, resolveWatchConfig(config))
control.signal.addEventListener('abort', () => { void this.dispose() }, { once: true })
// 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.
// must see only its explicit roots, or every such provider would
// re-discover the app's bundled skills 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

@@ -830,7 +830,7 @@ describe('LocalSkillProvider', () => {
// 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.
// drop it — isolated providers never re-claim the app's builtins.
const isolated = new Context()
await isolated.plugin(SkillService)
const customOnly = join(envHome, 'custom-only')