Merge origin/master into worktree/ci-native-windows-20260808
# Conflicts: # vendor/README.md
This commit is contained in:
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
|
||||
README.md: c256b89288e3e384c1dd3e64629a06d7cfef31f6
|
||||
README.zh.md: 88d1c4ad0ced2f5a6440a1b64e34e738a842f938
|
||||
README.zh.md: 454d504be72c2120ec4aeaa1b22545e7d6ba2fee
|
||||
|
||||
@@ -31,6 +31,8 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面
|
||||
|
||||
此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。
|
||||
|
||||
<a id="profiles"></a>
|
||||
|
||||
## Profile
|
||||
|
||||
profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录(Harness home 由 [`resolveDshHome`](../../util/paths/README.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和用户自己的 `cordis.patch.yml`。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则大声失败。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录(安装目录的应用与各组合包依赖的每个包对应一个符号链接),使任意 profile 中的裸插件名都能经 Node 常规的逐级向上查找解析,而 pnpm 从不管理随安装内置的包。`PROFILE_TEMPLATES`(`web`、`headless`)在首次使用时自动初始化;其他名称在 `initProfile` 创建之前都会大声失败(即 `dsh plugin` 路径)。
|
||||
|
||||
@@ -36,14 +36,14 @@ describe('RepositoryCache', () => {
|
||||
calls.push(directory)
|
||||
await fakePackage(directory)
|
||||
}
|
||||
const cache = new RepositoryCache(root, install)
|
||||
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, async () => { throw new Error('cache miss') })
|
||||
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}`,
|
||||
@@ -68,8 +68,8 @@ describe('RepositoryCache', () => {
|
||||
const specifier = 'github:owner/repository#race'
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
new RepositoryCache(root, install).resolve(specifier),
|
||||
new RepositoryCache(root, install).resolve(specifier),
|
||||
new RepositoryCache(root, { install }).resolve(specifier),
|
||||
new RepositoryCache(root, { install }).resolve(specifier),
|
||||
])
|
||||
|
||||
expect(second).toBe(first)
|
||||
@@ -80,11 +80,11 @@ describe('RepositoryCache', () => {
|
||||
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, async (directory) => {
|
||||
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([])
|
||||
@@ -94,7 +94,7 @@ describe('RepositoryCache', () => {
|
||||
|
||||
it('rejects empty or padded specifiers before touching the cache', async () => {
|
||||
const root = await temporaryRoot('repository-input')
|
||||
const cache = new RepositoryCache(root, fakePackage)
|
||||
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([])
|
||||
@@ -107,35 +107,69 @@ describe('RepositoryCache', () => {
|
||||
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, async () => { throw new Error('must not reinstall') })
|
||||
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('selects and prepares a root .dsh-plugin Git subpath through the bundled pnpm', { timeout: 60_000 }, async () => {
|
||||
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, 'build-helper'), { recursive: true })
|
||||
await mkdir(join(repository, 'prepare-helper'), { recursive: true })
|
||||
await mkdir(join(repository, 'skills', 'fixture'), { recursive: true })
|
||||
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, 'build-helper', 'package.json'), `${JSON.stringify({
|
||||
name: 'repository-build-helper',
|
||||
version: '1.0.0',
|
||||
bin: 'index.js',
|
||||
})}\n`)
|
||||
await writeFile(join(repository, '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, '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, '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'}\\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: { prepare: 'node prepare.mjs' },
|
||||
scripts: { prepack: 'repository-build-helper && dsh-plugin-prepare' },
|
||||
devDependencies: {
|
||||
'repository-build-helper': 'file:../build-helper',
|
||||
'repository-prepare-helper': 'file:../prepare-helper',
|
||||
},
|
||||
dsh: { skills: ['../skills'] },
|
||||
})}\n`)
|
||||
await writeFile(join(repository, '.dsh-plugin', 'prepare.mjs'), [
|
||||
"import { cp, mkdir, writeFile } from 'node:fs/promises'",
|
||||
"await mkdir('dsh-plugin-assets/skills', { recursive: true })",
|
||||
"await cp('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })",
|
||||
"await writeFile('dsh-plugin.mjs', 'export function apply() {}\\n')",
|
||||
"await writeFile('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)",
|
||||
'',
|
||||
].join('\n'))
|
||||
await execFileAsync('git', ['init', '--quiet'], { cwd: repository })
|
||||
await execFileAsync('git', ['add', '.'], { cwd: repository })
|
||||
await execFileAsync('git', [
|
||||
@@ -149,6 +183,7 @@ describe('RepositoryCache', () => {
|
||||
vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden')
|
||||
|
||||
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\n')
|
||||
await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply')
|
||||
await expect(readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8'))
|
||||
|
||||
@@ -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/core/scope/README.md
|
||||
README.md: 4f32573779a15e8c34b4936bfe75549dfc86d9f6
|
||||
README.zh.md: 16ec60a5489f909a46fd5f803dbf08490cd07988
|
||||
README.md: ecb442e39e40d5b97a07ccf8a71a190c4009ede8
|
||||
README.zh.md: f223d339129bcbef5c5f19ba6d0b453874944c4b
|
||||
|
||||
@@ -23,7 +23,7 @@ The optional `@deepseek-ai/dsh-scope/invariant` companion owns that runtime asse
|
||||
|
||||
## Design contract
|
||||
|
||||
The registration context determines both visibility and ownership, preventing a registration from being visible in one scope but disposed with another. Scopes route trusted same-process plugins; they are not sandboxes or authority boundaries. See the [agent-scope Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals) for rationale and security non-goals.
|
||||
The registration context determines both visibility and ownership, preventing a registration from being visible in one scope but disposed with another. Scopes route trusted same-process plugins; they are not sandboxes or authority boundaries. See the [agent-scope Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals) for rationale and security non-goals.
|
||||
|
||||
Scope-aware services define a concrete `ScopeLayer` that aggregates their heterogeneous tables and domain helpers. `ScopedLayers.effect()` accepts one synchronous action returning one synchronous undo, installs that undo before optional notification, and reclaims an exact-scope layer only when the complete aggregate is empty. `notify` defaults to `true`; the supplied callback owns whether observer failures throw or are contained. `EntryValues` remains internal, the storage classes are imported from the package root rather than a `/store` subpath, and the shared storage does not define registry-specific filtering or iteration policy. See the [shared scoped-layer storage Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md).
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
## 设计契约
|
||||
|
||||
注册上下文同时决定可见性和所有权,防止注册在一个作用域中可见、却随另一个作用域 dispose(资源释放)。作用域用于路由受信任的同进程插件;它们不是沙箱或权限边界。原理与明确排除的安全目标见 [agent 作用域 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals)。
|
||||
注册上下文同时决定可见性和所有权,防止注册在一个作用域中可见、却随另一个作用域 dispose(资源释放)。作用域用于路由受信任的同进程插件;它们不是沙箱或权限边界。原理与明确排除的安全目标见 [agent 作用域 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。
|
||||
|
||||
感知作用域的服务会定义具体 `ScopeLayer`,聚合各自不同的表与领域辅助函数。`ScopedLayers.effect()` 接受一个返回同步撤销函数的同步动作,在可选通知前安装该撤销函数,并且只有在完整聚合为空时才回收精确作用域层。`notify` 默认为 `true`;由所提供的回调决定观测方失败是向外抛出还是在内部处理。`EntryValues` 保持内部可见;存储类从包根而非 `/store` 子路径导入;共享存储不定义注册表专属的筛选或迭代策略。详见[共享作用域层存储 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)。
|
||||
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/system-prompt/README.md
|
||||
README.md: e98c45a8829a945500ef5282d84e1904b31c68bf
|
||||
README.zh.md: 5b9e2feaf82866a52cd8197ff5e800decdf3ee7e
|
||||
README.zh.md: bf659ea9961a9bb796c1ee1045ac7dad22bb997f
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void`:贡献提示词变量,在段文本中以 `{{name}}` 引用。带作用域变量会为该 agent 遮蔽同名全局变量。同层重复或无法引用的名称会抛出;`undefined` 表示「本次组装没有值」。随调用 fiber 一并 dispose。
|
||||
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>`:为一个调用方组装提示词:将全局层与 `context.scope` 的层合并,并在变换 seam 前分离工具 schema。它经过按作用域筛选的 `system-prompt/assemble` waterfall,并返回其权威结果。可选的 `context.signal` 显式控制本次组装请求;提供方与监听器可以配合该信号,但不得将它保留给另一轮次。当已配置的 `toolOrder` 指名提供方 `knownNames` 全集以外的工具,或提供方返回保留的其余项名称时,调用会被拒绝。
|
||||
|
||||
<a id="live-events"></a>
|
||||
|
||||
### 实时事件
|
||||
|
||||
`system-prompt/assemble` 是权威来源;替换条目的监听器必须保留任何活动 Code Mode 或结构化输出协议。筛选需要在呈现、查找与执行之间保持一致时,应使用 [`ToolRegistry.restrict()`](../tools/README.md)。注册表变更通知不经过筛选。[system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) 的生成区块拥有签名与分发契约。
|
||||
|
||||
@@ -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/core/tools/README.md
|
||||
README.md: f3f25f908a4ba8016d38f7777fe72691dba4afb1
|
||||
README.zh.md: 796ee8e1aa8de88f979d825df5e0b4f39ab7bcf1
|
||||
README.md: 97aa97c6ca7675ff5b891e62224a4d1d7780ef42
|
||||
README.zh.md: f402722c81e1c7be9f45202a7bc79bfc85a41512
|
||||
|
||||
@@ -18,7 +18,7 @@ tools:
|
||||
### Public API
|
||||
|
||||
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition with a mandatory canonical `output` declaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Missing or unsupported output declarations and a non-positive or non-finite `timeoutMs` fail at registration. The optional synchronous `finalizeContent` callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized, including an error discovered while materializing another result field. Disposed with the calling fiber.
|
||||
- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals).
|
||||
- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).
|
||||
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed.
|
||||
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)).
|
||||
- `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber.
|
||||
|
||||
@@ -18,7 +18,7 @@ tools:
|
||||
### 公开 API
|
||||
|
||||
- `ctx.tools.register(definition: ToolDefinition): () => void`:注册一个受信任、带类型的同进程定义,其中必须包含规范的 `output` 声明。所在层由调用上下文的作用域决定:普通插件上下文会全局注册;agent 的 `agent.ctx` 只为该 agent 注册,并在此处遮蔽同名全局工具。同一层内名称重复会抛出;非原生模式还会拒绝保留的 `run_code` 传输名称。缺失或不受支持的输出声明,以及非正数或非有限的 `timeoutMs`,都会使注册失败。可选的同步 `finalizeContent` 回调会在调用开始时创建快照;在所有流水线结果规范化之后,它只能替换最终面向模型的内容,包括实体化其他结果字段时发现的错误。随调用 fiber dispose(资源释放)。
|
||||
- `ctx.tools.restrict(filter)`:对全局工具应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。筛选器在注册时创建快照;多个掩码取交集,随后再合并作用域本地工具。拒绝掩码会接纳后来出现且未点名的全局工具,而允许掩码会排除后来出现的名称。未知、本地或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals)。
|
||||
- `ctx.tools.restrict(filter)`:对全局工具应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。筛选器在注册时创建快照;多个掩码取交集,随后再合并作用域本地工具。拒绝掩码会接纳后来出现且未点名的全局工具,而允许掩码会排除后来出现的名称。未知、本地或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。
|
||||
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined`:按某个作用域所见的结果解析(应用遮蔽;被限制掉的全局工具视为不存在)。呈现器会传入发起调用的 agent,使卡片与实际执行内容一致。
|
||||
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]`:返回该作用域可见的所有 schema(不含 `execute` 函数)。已交付工具的 schema 收录在 [docs/tool-catalog.md](../../../docs/tool-catalog.md) 中;该目录通过启动每个工具插件并采集此方法的结果生成(参见[工具 schema 目录 Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md))。
|
||||
- `ctx.tools.guard(guard: ToolGuard): () => void`:在 `tools/pre-execute` 之后注册单调同步执行守卫:返回理由会拒绝调用,返回 `undefined` 则保持原决定。普通上下文守卫全局生效;`agent.ctx` 守卫只对该 agent 生效。后续 waterfall(瀑布式事件)监听器无法将守卫的拒绝重新变为允许。随调用 fiber dispose。
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/credentials/credentials-local/README.md
|
||||
README.md: 462618b990f8d07e9b855248db1e149b3c673964
|
||||
README.zh.md: 55cc4411fcc9db9f13ae0077eaf2bce7871a0cd5
|
||||
README.zh.md: 050216bb4b4d22ef53ab0823eef09c1aaf7d1170
|
||||
|
||||
@@ -49,6 +49,8 @@ OPENAI_API_KEY: sk-…
|
||||
|
||||
外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。在 Chokidar 打开目标之前,提供方会对层级最深的现有祖先路径执行 realpath 解析,再拼回缺失的后缀;文件访问和诊断仍使用配置路径,从而避免 Windows 混用 8.3 别名与 libuv 的长格式事件路径。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则明确报错。
|
||||
|
||||
<a id="security-boundary"></a>
|
||||
|
||||
## 安全边界
|
||||
|
||||
文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境——这与用户的普通环境层 `$DSH_HOME/.env` 不同(见 [app-boot 的 Harness home 各层](../../boot/app-boot/README.md#profiles))——因此要拿到这个值,需要刻意去读一条并未交给 agent(智能体)的路径。
|
||||
|
||||
@@ -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/fs/tool-fs/README.md
|
||||
README.md: 28880860dc6c89745eb21fbf732d04f596f9b07f
|
||||
README.zh.md: 8af0aec51e71681211bbd5a4f582be8b8b0271b8
|
||||
README.md: 6f96970d8194c0992f9b955b95aec092185054b2
|
||||
README.zh.md: 2de8b02cd47f07af5a04a85694aec05214772a7f
|
||||
|
||||
@@ -150,4 +150,4 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
- **No model-facing directory listing ships** — `ctx.fs.listDir` serves provider code such as skill discovery, while the sibling [`dsh-tool-fs-search`](../tool-fs-search/) package supplies bash-backed `glob` and `grep` rather than extending the filesystem seam.
|
||||
- **`read` handles UTF-8 text files only** — binary-safe reads and PDF/image/multimodal content are deferred; a directory target is `FS_NOT_REGULAR_FILE`.
|
||||
- **No timeout surface** — `read`/`write`/`edit` take no timeout argument and declare no `timeout-policy` budget; cancellation rides `exec.signal` only ([provider rationale](../fs/README.md#no-io-deadline)).
|
||||
- **No timeout surface** — `read`/`write`/`edit` take no timeout argument and declare no `timeout-policy` budget; cancellation rides `exec.signal` only ([provider rationale](../README.md#no-timeouts-on-file-io)).
|
||||
|
||||
@@ -150,4 +150,4 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
|
||||
|
||||
- **未交付面向模型的目录列表工具**:`ctx.fs.listDir` 服务于 skill(技能)发现等提供方代码,同级 [`dsh-tool-fs-search`](../tool-fs-search/) 包则提供基于 bash 的 `glob` 与 `grep`,而不是扩展文件系统 seam。
|
||||
- **`read` 只处理 UTF-8 文本文件**:二进制安全读取和 PDF/图像/多模态内容均延期处理;目录目标为 `FS_NOT_REGULAR_FILE`。
|
||||
- **没有超时接口**:`read`/`write`/`edit` 不接受超时参数,也不声明 `timeout-policy` 预算;取消只通过 `exec.signal` 传递(见[提供方理由](../fs/README.md#no-io-deadline))。
|
||||
- **没有超时接口**:`read`/`write`/`edit` 不接受超时参数,也不声明 `timeout-policy` 预算;取消只通过 `exec.signal` 传递(见[提供方理由](../README.md#no-timeouts-on-file-io))。
|
||||
|
||||
@@ -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/mcp/mcp-client/README.md
|
||||
README.md: d7966595c68ff1ec4a288caf5d9fe4b0bf580cc5
|
||||
README.zh.md: eb9e0dbdb48423cc4bc698fda355e973e42bc7a3
|
||||
README.md: 76d1271f6f7a3e9c959bdcf5e969906f25563c56
|
||||
README.zh.md: 49de996863ab16a46cd7ee82b13624523dbb853f
|
||||
|
||||
@@ -44,6 +44,7 @@ The model sees `mcp__github__create_issue`, `mcp__web__search`, … — the same
|
||||
| `url` | http | yes | MCP server URL |
|
||||
| `headers` | http | no | Extra headers (e.g. auth tokens) |
|
||||
| `toolCallTimeoutMs` | both | no | Timeout per `callTool` invocation (default 60000) |
|
||||
| `failOnStartupError` | both | no | Reject plugin activation when initial connection or tool synchronization fails (default `false`) |
|
||||
|
||||
## Tool naming
|
||||
|
||||
@@ -56,8 +57,8 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call`
|
||||
|
||||
## Behavior
|
||||
|
||||
- On connect: `listTools()` → registers each tool via `ctx.tools.register()` under its public name.
|
||||
- Listens for `notifications/tools/list_changed` → re-syncs; a failed re-sync keeps the previous generation registered.
|
||||
- On connect: plugin activation awaits `listTools()` and registers each tool via `ctx.tools.register()` under its public name before the composition starts its first turn. Initial connection, discovery, or registration failure is always logged; it rejects activation when `failOnStartupError` is true and otherwise activates with no tools.
|
||||
- Listens for `notifications/tools/list_changed` → re-syncs; a fetch-phase failure keeps the previous generation registered, while a registration conflict rolls back the attempted generation and leaves no tools from that server.
|
||||
- Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support—the public name is never sent to the server.
|
||||
- Canonical success is `{ content: JsonValue[], structuredContent? }`; complete JSON MCP blocks survive for programmatic callers. A supported advertised `outputSchema` validates `structuredContent`; unsupported schema vocabulary falls back to unconstrained `JsonValue`.
|
||||
- Native/model rendering keeps the existing text projection: text blocks join with newlines while image, audio, resource, and unsupported blocks become placeholders.
|
||||
@@ -101,8 +102,8 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Initial discovery is asynchronous** — plugin load does not wait for connection and `listTools()`, so a turn started immediately after boot or HMR can assemble before the MCP tools are registered.
|
||||
- **Tools are the only bridged MCP capability** — Resources and Prompts have no harness consumption surface and are deferred.
|
||||
- **Startup timeout is inherited from the MCP SDK** — DSH does not yet expose a connection/discovery timeout. Each initialize or paginated `tools/list` request uses the SDK's 60-second default, so an unresponsive server or cursor chain can delay both activation and teardown while the initial synchronization settles.
|
||||
- **Crash recovery is manual** — transport closure does not auto-reconnect; registered tools can remain visible but fail against the closed transport until an HMR reload or Host restart.
|
||||
- **Native non-text rendering is lossy** — image, audio, and resource payloads become placeholders in model context even though the execution-local canonical value preserves their JSON blocks. Richer Native multimedia projection is deferred.
|
||||
- **Unsupported MCP output schemas are not enforced** — `structuredContent` falls back to `JsonValue` when the advertised schema uses vocabulary outside the harness subset.
|
||||
|
||||
@@ -44,6 +44,7 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc
|
||||
| `url` | http | 是 | MCP 服务器 URL |
|
||||
| `headers` | http | 否 | 额外标头(例如认证 token) |
|
||||
| `toolCallTimeoutMs` | 两者 | 否 | 每次 `callTool` 调用的超时(默认 60000) |
|
||||
| `failOnStartupError` | 两者 | 否 | 初始连接或工具同步失败时拒绝插件激活(默认 `false`) |
|
||||
|
||||
## 工具命名
|
||||
|
||||
@@ -56,8 +57,8 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc
|
||||
|
||||
## 行为
|
||||
|
||||
- 连接时:`listTools()` → 通过 `ctx.tools.register()` 使用各自公开名称注册每个工具。
|
||||
- 监听 `notifications/tools/list_changed` → 重新同步;同步失败时保留上一世代的注册。
|
||||
- 连接时:插件激活会等待 `listTools()`,并在组合开始首个轮次前通过 `ctx.tools.register()` 以公开名称注册每个工具。初始连接、发现或注册失败始终会记录日志;`failOnStartupError` 为 true 时拒绝激活,否则插件仍会激活但不注册工具。
|
||||
- 监听 `notifications/tools/list_changed` → 重新同步;获取阶段失败时保留上一世代的注册,注册冲突则会回滚本次尝试的世代,并且不保留该服务器的任何工具。
|
||||
- 工具执行:`client.callTool({ name: rawName, arguments }, { signal })`,支持超时 + 中止;公开名称绝不会发给服务器。
|
||||
- 规范成功值是 `{ content: JsonValue[], structuredContent? }`;完整的 JSON MCP 块会保留给编程调用方。受支持且已声明的 `outputSchema` 会验证 `structuredContent`;不受支持的 schema 词汇会回退为不受约束的 `JsonValue`。
|
||||
- Native/模型渲染保留现有文本投影:文本块以换行连接,图片、音频、资源和不受支持的块会变成占位符。
|
||||
@@ -101,8 +102,8 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **初始发现是异步的**:插件加载不会等待连接和 `listTools()`,因此在启动或 HMR 后立即开始的轮次可能在 MCP 工具注册前完成组装。
|
||||
- **只桥接 MCP 的工具能力**:资源和提示词没有 harness 消费接口,暂缓实现。
|
||||
- **启动超时继承自 MCP SDK**:DSH 尚未公开连接/发现超时。每次 initialize 请求或分页 `tools/list` 请求都使用 SDK 默认的 60 秒,因此在初始同步完成期间,无响应的 server 或 cursor chain 可能同时延迟激活与 teardown。
|
||||
- **崩溃恢复需要手动触发**:传输关闭后不会自动重新连接;已注册工具可能仍然可见,但会因传输已关闭而调用失败,直到 HMR 重载或重启 Host。
|
||||
- **Native 非文本渲染有损**:图片、音频与资源载荷在模型上下文中会变成占位符,即使执行局部的规范值保留了其 JSON 块。更丰富的 Native 多媒体投影暂缓实现。
|
||||
- **不强制执行不受支持的 MCP 输出 schema**:已声明 schema 使用 harness 子集之外的词汇时,`structuredContent` 会回退到 `JsonValue`。
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface StdioConfig {
|
||||
cwd: string
|
||||
/** Per-tool-call timeout in milliseconds. */
|
||||
toolCallTimeoutMs: number
|
||||
/** Fail plugin activation when the initial connection or tool synchronization fails. */
|
||||
failOnStartupError: boolean
|
||||
}
|
||||
|
||||
/** Config for connecting to an MCP server over Streamable HTTP (SSE). */
|
||||
@@ -90,6 +92,8 @@ export interface StreamableHttpConfig {
|
||||
headers: Record<string, string>
|
||||
/** Per-tool-call timeout in milliseconds. */
|
||||
toolCallTimeoutMs: number
|
||||
/** Fail plugin activation when the initial connection or tool synchronization fails. */
|
||||
failOnStartupError: boolean
|
||||
}
|
||||
|
||||
/** Configuration for one stdio or Streamable HTTP MCP server. */
|
||||
@@ -104,6 +108,7 @@ export const Config = z.union([
|
||||
env: z.dict(String).default({}),
|
||||
cwd: z.string().default(''),
|
||||
toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS),
|
||||
failOnStartupError: z.boolean().default(false),
|
||||
}),
|
||||
z.object({
|
||||
transport: z.const('streamable-http'),
|
||||
@@ -111,12 +116,21 @@ export const Config = z.union([
|
||||
url: z.string().required(),
|
||||
headers: z.dict(String).default({}),
|
||||
toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS),
|
||||
failOnStartupError: z.boolean().default(false),
|
||||
}),
|
||||
]) as unknown as z<Config>
|
||||
|
||||
// ---- Plugin apply ----
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
/**
|
||||
* Connect one MCP server and publish its initial tool generation before activation.
|
||||
* This entry remains explicitly `async`: Cordis treats a prototype-bearing
|
||||
* ordinary function as a constructor, whose returned Promise is not startup work.
|
||||
* @param ctx - plugin context carrying the tool registry.
|
||||
* @param config - resolved transport and server namespace configuration.
|
||||
* @returns startup readiness after connection and initial tool discovery settle.
|
||||
*/
|
||||
export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
// Reserve the namespace first: a duplicate `serverName` fails THIS instance
|
||||
// at load with an actionable error and leaves the earlier instance intact.
|
||||
ctx.effect(() => {
|
||||
@@ -141,18 +155,22 @@ export function apply(ctx: Context, config: Config): void {
|
||||
)
|
||||
|
||||
const opts = {
|
||||
registrationFailure: 'contain' as const,
|
||||
serverName: config.serverName,
|
||||
toolCallTimeoutMs: config.toolCallTimeoutMs,
|
||||
}
|
||||
|
||||
// Connect and set up tools. Errors during connect/first sync are logged,
|
||||
// not thrown (the plugin simply has no tools registered). `ready` resolves
|
||||
// to an accessor for the CURRENT disposer generation, so the effect
|
||||
// disposer below always unregisters the live set, not the first one.
|
||||
// Connect and set up tools. `ready` always settles to an outcome so rollback
|
||||
// can close a partially opened client even when strict startup later rejects.
|
||||
// Its accessor returns the CURRENT disposer generation, so disposal always
|
||||
// unregisters the live set, not the first one.
|
||||
const ready = (async () => {
|
||||
await client.connect(transport)
|
||||
|
||||
let disposers = await syncTools(client, ctx, opts, new Map())
|
||||
let disposers = await syncTools(client, ctx, {
|
||||
...opts,
|
||||
registrationFailure: config.failOnStartupError ? 'throw' : 'contain',
|
||||
}, new Map())
|
||||
|
||||
client.setNotificationHandler(
|
||||
ToolListChangedNotificationSchema,
|
||||
@@ -168,15 +186,20 @@ export function apply(ctx: Context, config: Config): void {
|
||||
},
|
||||
)
|
||||
|
||||
return () => disposers
|
||||
return { getDisposers: () => disposers }
|
||||
})().catch((error: unknown) => {
|
||||
ctx.logger.error(`mcp-client(${config.serverName}): failed to connect: ${String(error)}`)
|
||||
return () => new Map<string, () => void>()
|
||||
ctx.logger.error(`mcp-client(${config.serverName}): startup failed: ${String(error)}`)
|
||||
return { getDisposers: () => new Map<string, () => void>(), error }
|
||||
})
|
||||
|
||||
ctx.effect(() => async () => {
|
||||
const live = await ready
|
||||
for (const dispose of live().values()) dispose()
|
||||
const outcome = await ready
|
||||
for (const dispose of outcome.getDisposers().values()) dispose()
|
||||
try { await client.close() } catch { /* transport already gone */ }
|
||||
}, 'mcp-client.connection')
|
||||
|
||||
const outcome = await ready
|
||||
if ('error' in outcome && config.failOnStartupError) {
|
||||
throw new Error(`mcp-client(${config.serverName}): initial connection or tool synchronization failed`, { cause: outcome.error })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import type { JsonSchemaNode, JsonValue } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** Resolved options relevant to tool bridging. */
|
||||
export interface ToolBridgeOptions {
|
||||
/** Whether a registry conflict is contained or rejects this synchronization. */
|
||||
registrationFailure: 'contain' | 'throw'
|
||||
serverName: string
|
||||
toolCallTimeoutMs: number
|
||||
}
|
||||
@@ -111,8 +113,9 @@ export function publicToolName(serverName: string, rawName: string): string {
|
||||
* 2. Swap: dispose the previous generation, register the new one. A registry
|
||||
* conflict here can only mean a foreign registration squats on this
|
||||
* server's `mcp__<serverName>__` namespace — the partial generation is
|
||||
* rolled back (zero tools from this server), the error is logged, and an
|
||||
* empty map is returned.
|
||||
* rolled back (zero tools from this server) and logged. Initial strict
|
||||
* synchronization may propagate the conflict so its parent transaction
|
||||
* rejects; ordinary clients and later re-syncs return an empty map.
|
||||
*
|
||||
* @param client - Connected MCP Client instance used to list and call tools.
|
||||
* @param ctx - Cordis context providing the `tools` service for registration.
|
||||
@@ -164,6 +167,7 @@ export async function syncTools(
|
||||
// sees either the full generation or none of it — never a partial set.
|
||||
for (const dispose of disposers.values()) dispose()
|
||||
ctx.logger.error(`mcp-client(${opts.serverName}): tool registration failed, no tools registered: ${String(error)}`)
|
||||
if (opts.registrationFailure === 'throw') throw error
|
||||
return new Map()
|
||||
}
|
||||
return disposers
|
||||
|
||||
@@ -82,6 +82,7 @@ const stdioConfig: Config = {
|
||||
env: {},
|
||||
cwd: '',
|
||||
toolCallTimeoutMs: 60_000,
|
||||
failOnStartupError: false,
|
||||
}
|
||||
|
||||
// ---- Tests ----
|
||||
@@ -141,8 +142,7 @@ describe('apply (plugin lifecycle)', () => {
|
||||
})
|
||||
|
||||
it('connects, syncs tools under the namespace, and registers a notification handler', async () => {
|
||||
apply(ctx, stdioConfig)
|
||||
await sleep(50)
|
||||
await apply(ctx, stdioConfig)
|
||||
|
||||
expect(mockConnect).toHaveBeenCalled()
|
||||
expect(mockListTools).toHaveBeenCalled()
|
||||
@@ -151,12 +151,30 @@ describe('apply (plugin lifecycle)', () => {
|
||||
expect(ctx.tools.get('remote')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the Cordis plugin loading until initial discovery publishes its tools', async () => {
|
||||
const connection: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
mockConnect.mockImplementation(async () => {
|
||||
await connection.promise
|
||||
})
|
||||
const fiber = ctx.plugin({ name: 'mcp-client-lifecycle', inject, apply }, stdioConfig)
|
||||
let activated = false
|
||||
const activation = Promise.resolve(fiber).then(() => { activated = true })
|
||||
|
||||
await vi.waitFor(() => { expect(mockConnect).toHaveBeenCalled() })
|
||||
expect(activated).toBe(false)
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
|
||||
|
||||
connection.resolve()
|
||||
await activation
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a duplicate serverName at load and leaves the first instance intact', async () => {
|
||||
apply(ctx, stdioConfig)
|
||||
await sleep(50)
|
||||
await apply(ctx, stdioConfig)
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
|
||||
expect(() => { apply(ctx, stdioConfig) }).toThrow(/serverName "srv" is already in use/)
|
||||
await expect(apply(ctx, stdioConfig)).rejects.toThrow(/serverName "srv" is already in use/)
|
||||
// First instance unaffected.
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
})
|
||||
@@ -165,8 +183,7 @@ describe('apply (plugin lifecycle)', () => {
|
||||
const first = new Context()
|
||||
await first.plugin(SystemPrompt)
|
||||
await first.plugin(ToolRegistry)
|
||||
apply(first, stdioConfig)
|
||||
await sleep(50)
|
||||
await apply(first, stdioConfig)
|
||||
|
||||
await first.fiber.dispose()
|
||||
await sleep(50)
|
||||
@@ -176,26 +193,26 @@ describe('apply (plugin lifecycle)', () => {
|
||||
const second = new Context()
|
||||
await second.plugin(SystemPrompt)
|
||||
await second.plugin(ToolRegistry)
|
||||
expect(() => { apply(second, stdioConfig) }).not.toThrow()
|
||||
await expect(apply(second, stdioConfig)).resolves.toBeUndefined()
|
||||
await second.fiber.dispose()
|
||||
})
|
||||
|
||||
it('scopes serverName reservations per app root', async () => {
|
||||
const other = await mountRegistry()
|
||||
|
||||
apply(ctx, stdioConfig)
|
||||
const first = apply(ctx, stdioConfig)
|
||||
// Same serverName on a DIFFERENT root is fine.
|
||||
expect(() => { apply(other, stdioConfig) }).not.toThrow()
|
||||
await sleep(50)
|
||||
const second = apply(other, stdioConfig)
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
expect(other.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
})
|
||||
|
||||
it('logs error and registers no tools when connect fails; dispose is a no-op', async () => {
|
||||
it('logs error and registers no tools when connect fails; dispose closes the client', async () => {
|
||||
mockConnect.mockRejectedValue(new Error('connection refused'))
|
||||
|
||||
apply(ctx, stdioConfig)
|
||||
await sleep(50)
|
||||
await apply(ctx, stdioConfig)
|
||||
|
||||
expect(mockListTools).not.toHaveBeenCalled()
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
|
||||
@@ -207,9 +224,43 @@ describe('apply (plugin lifecycle)', () => {
|
||||
expect(mockClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects activation and still closes the client when startup failure is configured as fatal', async () => {
|
||||
mockConnect.mockRejectedValue(new Error('connection refused'))
|
||||
await expect(apply(ctx, {
|
||||
...stdioConfig,
|
||||
failOnStartupError: true,
|
||||
})).rejects.toThrow('initial connection or tool synchronization failed')
|
||||
|
||||
expect(mockListTools).not.toHaveBeenCalled()
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
expect(mockClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects strict startup when the initial tool generation cannot be registered', async () => {
|
||||
ctx.tools.register({
|
||||
name: 'mcp__srv__remote',
|
||||
description: 'Foreign squatter',
|
||||
parameters: { type: 'object' },
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value as string }],
|
||||
},
|
||||
execute: async () => 'foreign',
|
||||
})
|
||||
|
||||
await expect(apply(ctx, {
|
||||
...stdioConfig,
|
||||
failOnStartupError: true,
|
||||
})).rejects.toThrow('initial connection or tool synchronization failed')
|
||||
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
expect(mockClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-syncs tools on ToolListChanged notification', async () => {
|
||||
apply(ctx, stdioConfig)
|
||||
await sleep(50)
|
||||
await apply(ctx, stdioConfig)
|
||||
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
|
||||
@@ -226,8 +277,7 @@ describe('apply (plugin lifecycle)', () => {
|
||||
})
|
||||
|
||||
it('keeps the previous generation when a re-sync fails', async () => {
|
||||
apply(ctx, stdioConfig)
|
||||
await sleep(50)
|
||||
await apply(ctx, stdioConfig)
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
|
||||
mockListTools.mockRejectedValue(new Error('flaky server'))
|
||||
@@ -242,7 +292,7 @@ describe('apply (plugin lifecycle)', () => {
|
||||
// Load through ctx.plugin so ONLY the plugin's fiber is disposed — the
|
||||
// registry must survive to observe the unregistration.
|
||||
const fiber = ctx.plugin({ name: 'mcp-client', inject: ['tools'], apply }, stdioConfig)
|
||||
await sleep(50)
|
||||
await fiber
|
||||
|
||||
// Advance to a second generation first.
|
||||
mockListTools.mockResolvedValue({
|
||||
@@ -264,8 +314,7 @@ describe('apply (plugin lifecycle)', () => {
|
||||
it('effect disposer handles client.close failure gracefully', async () => {
|
||||
mockClose.mockRejectedValue(new Error('already closed'))
|
||||
|
||||
apply(ctx, stdioConfig)
|
||||
await sleep(50)
|
||||
await apply(ctx, stdioConfig)
|
||||
|
||||
// Should not throw when dispose is triggered.
|
||||
await ctx.fiber.dispose()
|
||||
@@ -281,10 +330,10 @@ describe('apply (plugin lifecycle)', () => {
|
||||
url: 'http://localhost:3000/mcp',
|
||||
headers: { Authorization: 'Bearer x' },
|
||||
toolCallTimeoutMs: 30_000,
|
||||
failOnStartupError: false,
|
||||
}
|
||||
|
||||
apply(ctx, httpConfig)
|
||||
await sleep(50)
|
||||
await apply(ctx, httpConfig)
|
||||
|
||||
expect(mockConnect).toHaveBeenCalled()
|
||||
expect(ctx.tools.get('mcp__web__remote')).toBeDefined()
|
||||
|
||||
@@ -43,21 +43,6 @@ async function mountRegistry(): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Apply the MCP client plugin and wait for tools to be registered. */
|
||||
async function applyAndWait(ctx: Context, config: Config, timeoutMs = 20_000): Promise<void> {
|
||||
// Annotated bindings (not withResolvers<void>()): the tests lint layer runs
|
||||
// no-invalid-void-type with default options, which rejects the explicit
|
||||
// type argument in call position but accepts the inferred form.
|
||||
const gate: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
const timer = setTimeout(
|
||||
() => { gate.reject(new Error(`applyAndWait timed out after ${timeoutMs}ms — no tools/change event`)) },
|
||||
timeoutMs,
|
||||
)
|
||||
ctx.on('tools/change', () => { clearTimeout(timer); gate.resolve() })
|
||||
apply(ctx, config)
|
||||
await gate.promise
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
const gate: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
setTimeout(gate.resolve, ms)
|
||||
@@ -90,11 +75,12 @@ describe('fixture server — controlled scenarios', () => {
|
||||
env: {},
|
||||
cwd: packageDir,
|
||||
toolCallTimeoutMs: 15_000,
|
||||
failOnStartupError: false,
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await mountRegistry()
|
||||
await applyAndWait(ctx, fixtureConfig)
|
||||
await apply(ctx, fixtureConfig)
|
||||
}, 30_000)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -179,10 +165,11 @@ describe('fixture server — duplicate serverName', () => {
|
||||
env: {},
|
||||
cwd: packageDir,
|
||||
toolCallTimeoutMs: 15_000,
|
||||
failOnStartupError: false,
|
||||
}
|
||||
await applyAndWait(ctx, config)
|
||||
await apply(ctx, config)
|
||||
|
||||
expect(() => { apply(ctx, config) }).toThrow(/serverName "dup" is already in use/)
|
||||
await expect(apply(ctx, config)).rejects.toThrow(/serverName "dup" is already in use/)
|
||||
|
||||
await ctx.fiber.dispose()
|
||||
await sleep(200)
|
||||
@@ -192,7 +179,7 @@ describe('fixture server — duplicate serverName', () => {
|
||||
describe('fixture server — disposal', () => {
|
||||
it('disposes cleanly without error', async () => {
|
||||
const ctx = await mountRegistry()
|
||||
await applyAndWait(ctx, {
|
||||
await apply(ctx, {
|
||||
transport: 'stdio',
|
||||
serverName: 'fixture',
|
||||
command: process.execPath,
|
||||
@@ -200,6 +187,7 @@ describe('fixture server — disposal', () => {
|
||||
env: {},
|
||||
cwd: packageDir,
|
||||
toolCallTimeoutMs: 15_000,
|
||||
failOnStartupError: false,
|
||||
})
|
||||
|
||||
// Tools are registered before dispose.
|
||||
@@ -225,11 +213,12 @@ describe('server-everything — official test server', () => {
|
||||
env: {},
|
||||
cwd: '',
|
||||
toolCallTimeoutMs: 30_000,
|
||||
failOnStartupError: false,
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await mountRegistry()
|
||||
await applyAndWait(ctx, config)
|
||||
await apply(ctx, config)
|
||||
}, 60_000)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -292,8 +281,9 @@ describe('server-filesystem — real filesystem operations', () => {
|
||||
env: {},
|
||||
cwd: '',
|
||||
toolCallTimeoutMs: 30_000,
|
||||
failOnStartupError: false,
|
||||
}
|
||||
await applyAndWait(ctx, config)
|
||||
await apply(ctx, config)
|
||||
}, 60_000)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -408,8 +398,9 @@ describe('streamable-http — in-process MCP server', () => {
|
||||
url: baseUrl,
|
||||
headers: { Authorization: 'Bearer e2e-test-token' },
|
||||
toolCallTimeoutMs: 15_000,
|
||||
failOnStartupError: false,
|
||||
}
|
||||
await applyAndWait(ctx, config)
|
||||
await apply(ctx, config)
|
||||
}, 30_000)
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -64,6 +64,7 @@ async function mountRegistry(): Promise<Context> {
|
||||
}
|
||||
|
||||
const defaultOpts: ToolBridgeOptions = {
|
||||
registrationFailure: 'contain',
|
||||
serverName: 'srv',
|
||||
toolCallTimeoutMs: 60_000,
|
||||
}
|
||||
@@ -713,6 +714,7 @@ describe('createTransport', () => {
|
||||
env: {},
|
||||
cwd: '/tmp',
|
||||
toolCallTimeoutMs: 60_000,
|
||||
failOnStartupError: false,
|
||||
}
|
||||
const transport = createTransport(config)
|
||||
expect(transport).toBeDefined()
|
||||
@@ -727,6 +729,7 @@ describe('createTransport', () => {
|
||||
url: 'http://localhost:3000/mcp',
|
||||
headers: {},
|
||||
toolCallTimeoutMs: 60_000,
|
||||
failOnStartupError: false,
|
||||
}
|
||||
const transport = createTransport(config)
|
||||
expect(transport).toBeDefined()
|
||||
@@ -741,6 +744,7 @@ describe('createTransport', () => {
|
||||
url: 'http://localhost:3000/mcp',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
toolCallTimeoutMs: 60_000,
|
||||
failOnStartupError: false,
|
||||
}
|
||||
const transport = createTransport(config)
|
||||
expect(transport).toBeDefined()
|
||||
@@ -764,6 +768,7 @@ describe('createTransport', () => {
|
||||
env: { EXTRA: 'injected' },
|
||||
cwd: '',
|
||||
toolCallTimeoutMs: 60_000,
|
||||
failOnStartupError: false,
|
||||
}
|
||||
// createTransport internally calls buildChildEnv; we verify by inspecting
|
||||
// the constructed StdioClientTransport. Since we can't inspect private fields
|
||||
@@ -791,6 +796,7 @@ describe('createTransport', () => {
|
||||
env: { CUSTOM: 'value' },
|
||||
cwd: '',
|
||||
toolCallTimeoutMs: 60_000,
|
||||
failOnStartupError: false,
|
||||
}
|
||||
const transport = createTransport(config)
|
||||
expect(transport).toBeDefined()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/self-modification/repository-plugin/README.md
|
||||
README.md: 33cd763d7dbe21b72f9e604b7b2e313081cf656f
|
||||
README.zh.md: 903dfbe601cc76acb0c1e87453dc03ef0321409b
|
||||
README.md: 666f00e02b9ab33bff348df6b4ff90e3f3bfecc7
|
||||
README.zh.md: 62f467dd9ccac904ea2a216242f5475c29734a86
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Restricted repository Plugin format for DeepSeek Harness. A repository author declares static skill roots and an optional common `.mcp.json` in `.dsh-plugin/package.json`; the prepare helper copies those assets and emits a fixed import-free Cordis wrapper. The runtime wrapper can only delegate to this DSH-owned package, which composes [`dsh-skill-local`](../../skill/skill-local/README.md) and [`dsh-mcp-client`](../../mcp/mcp-client/README.md). Design rationale: [static repository Plugin format Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md).
|
||||
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
|
||||
|
||||
@@ -13,20 +13,31 @@ Place an ordinary package in the repository's `.dsh-plugin` directory:
|
||||
"name": "humanize-dsh-plugin",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"prepare": "dsh-plugin-prepare"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-repository-plugin": "^0.0.1"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`dsh.skills` is an optional array of local skill roots. `dsh.mcpServers` is an optional path to one `.mcp.json`; at least one field is required. Paths are relative to `.dsh-plugin`, must stay under its parent source directory, and may therefore refer to existing repository assets such as `../skills`. A repository containing several Plugins gives each one its own `.dsh-plugin` package under a different selectable subdirectory.
|
||||
`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
|
||||
|
||||
@@ -43,23 +54,23 @@ The shipped `dsh-base` bundle every profile starts from contains an empty `repos
|
||||
|
||||
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
|
||||
|
||||
`dsh-plugin-prepare` validates `package.json#dsh`, verifies skill-root types, parses the MCP file, copies assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. The wrapper contains only the normalized static manifest and fixed code that looks up the `dsh-repository-plugin` Loader builtin. It neither discovers nor compiles repository JavaScript, and the runtime never imports another repository entry point.
|
||||
|
||||
The containing package manager still runs the configured repository package's lifecycle scripts. This restriction defines the supported DSH contribution surface; it is not a security boundary for a repository that the user chose to install as executable package-manager source.
|
||||
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 to that builtin with its own module URL and prepared manifest. The runtime validates every declared skill root as an existing in-package directory before mounting — a package whose generated outputs were dropped (a `files`/`.npmignore` mistake, a damaged cache entry) fails the plugin load instead of silently mounting a skill-less plugin. Repository skill roots mount as a uniquely named `dsh-skill-local` provider with default project/user roots excluded and watching disabled; cached package generations are immutable. Wrapper disposal removes the provider and all composed MCP clients through normal Cordis child-fiber teardown.
|
||||
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; a network or child-process connection failure retains that client's established log-and-no-tools behavior.
|
||||
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
|
||||
|
||||
@@ -95,8 +106,23 @@ Conditional on successful connection and the remote tool list; schemas recur on
|
||||
|
||||
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
|
||||
|
||||
- **Skills and MCP only** — commands, hooks, agents, apps, arbitrary Cordis code, marketplaces, and compatibility shims are intentionally outside this format.
|
||||
- **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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
这是 DeepSeek Harness 的受限 repository 插件格式。仓库作者在 `.dsh-plugin/package.json` 中声明静态 skill(技能)根和可选的通用 `.mcp.json`;prepare helper 会复制这些资源并生成固定、无 import 的 Cordis 包装模块。运行时包装模块只能委托给这个由 DSH 自有的包,再由它组合 [`dsh-skill-local`](../../skill/skill-local/README.md) 与 [`dsh-mcp-client`](../../mcp/mcp-client/README.md)。设计依据见[静态 repository 插件格式 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md)。
|
||||
这是 DeepSeek Harness 的受信任 repository 包格式。`.dsh-plugin` NPM 包可以贡献已编译的 Cordis/DSH 插件入口、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)。
|
||||
|
||||
## 创作格式
|
||||
|
||||
@@ -13,20 +13,31 @@
|
||||
"name": "humanize-dsh-plugin",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"prepare": "dsh-plugin-prepare"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-repository-plugin": "^0.0.1"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`dsh.skills` 是可选的本地 skill 根数组。`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径;两者至少声明一个。路径相对于 `.dsh-plugin`,必须留在其父级源码目录下,因此可以引用 `../skills` 等仓库现有资源。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` 包。
|
||||
`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 和不可变缓存提供身份与可复现性,而非隔离。
|
||||
|
||||
## 独立应用配置
|
||||
|
||||
@@ -43,23 +54,23 @@
|
||||
|
||||
每个源都必须采用 `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)。
|
||||
|
||||
## 准备阶段
|
||||
|
||||
`dsh-plugin-prepare` 校验 `package.json#dsh`、确认 skill 根类型、解析 MCP 文件、把资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。包装模块只包含规范化后的静态 manifest(元数据清单),以及查找 `dsh-repository-plugin` Loader builtin 的固定代码;它不会发现或编译仓库 JavaScript,运行时也不会导入仓库的其他入口。
|
||||
|
||||
外层包管理器仍会运行已配置仓库包的生命周期脚本。这里的限制只定义 DSH 所支持的贡献表面;对于用户选择以可执行包管理器源安装的仓库,它并不是安全边界。
|
||||
安装精确指定的 Git 源时,DSH 随附的 pnpm 会按所选包自身的 manifest 安装。由事务持有的 `pnpm` 包装脚本会以 `--ignore-workspace` 重新调用同一份锁定的 pnpm,因此外层 workspace lockfile 无法抑制仅由所选 `.dsh-plugin` 包声明的依赖。必需的 `prepack` 生命周期在该依赖安装完成后、选定子目录打包前运行;其常规 `node_modules/.bin` 查找会从直接声明的 `@deepseek-ai/dsh-repository-plugin` 开发依赖中取得 `dsh-plugin-prepare`。该包把 Cordis/DSH 运行时对等依赖(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。每个生成的包装模块都把自身模块 URL 和已准备的 manifest 委托给该 builtin。运行时在挂载前会校验每个声明的 skill 根都是包内实际存在的目录——生成输出被丢弃的包(`files`/`.npmignore` 配置失误、缓存条目损坏)会使插件加载失败,而不是静默挂载一个没有 skill 的插件。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存包 generation 是不可变的。包装模块 dispose(资源释放)时,会通过正常的 Cordis 子 fiber teardown 移除提供方和所有组合的 MCP client。
|
||||
加载本包会注册一个 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` transport;stdio 条目以已准备的包目录作为 `cwd`。
|
||||
|
||||
未知字段会被拒绝,包括 OAuth 字段与 `auth` 对象。不提供 `CLAUDE_PLUGIN_ROOT` 展开或兼容层。完成格式转换后,现有 `dsh-mcp-client` 独占 transport 创建、连接诊断、工具同步、调用和断开生命周期;网络或子进程连接失败沿用该 client 既有的“记录错误且不注册工具”行为。
|
||||
未知字段会被拒绝,包括 OAuth 字段与 `auth` 对象。不提供 `CLAUDE_PLUGIN_ROOT` 展开或兼容层。完成格式转换后,现有 `dsh-mcp-client` 独占 transport 创建、连接诊断、工具同步、调用和断开生命周期。Repository 声明的 server 会启用其严格启动模式:插件激活会等待初始连接与工具同步,因此首个模型请求会看到已完整注册的初始工具 generation;网络、子进程、发现或注册失败则会拒绝候选 repository generation,而不是在缺少已声明工具的情况下静默激活。
|
||||
|
||||
## 导出形状
|
||||
|
||||
@@ -95,8 +106,23 @@ Namespace 插件:具名导出 `name`/`inject`/`apply`、准备阶段常量
|
||||
|
||||
稳定的已连接工具列表保持前缀稳定。插件生命周期或 MCP 工具列表变化可能从首个受影响定义开始改变后续工具 schema 前缀。
|
||||
|
||||
### Repository 代码
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
取决于数据。受信任的 Cordis 入口可以通过其声明的服务和事件贡献任意可用的 DSH 行为,包括工具、提示词片段、策略、命令和转换。每项模型可见贡献仍受所属 DSH seam 的日志与生命周期契约约束。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
由入口贡献的服务和注册决定;repository 格式本身不添加模型内容。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
稳定的注册会保留所属表面的正常前缀行为。加载、移除或替换精确的 repository generation,可能改变受该插件影响的任意前缀。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **仅支持 skill 与 MCP**:commands、钩子、agent(智能体)、apps、任意 Cordis 代码、marketplace 和兼容 shim 均有意排除在该格式之外。
|
||||
- **没有代码沙箱**:`dsh.entry`、NPM 依赖和包生命周期脚本以 DSH 宿主权限执行;必须信任该 repository。
|
||||
- **入口专用服务依赖不会预先门控**:生成的包装层无法在导入入口模块前声明其 `inject`。除 skill 或 MCP 隐含的服务外,其他任何服务在包装层挂载入口时都必须已经存在,否则该 repository generation 会被拒绝。
|
||||
- **没有 MCP 认证协议**:静态 header 可以使用环境变量展开,但带 OAuth 的定义会被拒绝,私有 server 登录流程不在此实现。
|
||||
- **生成资源是不可变运行时输入**:repository cache generation 不受监视;必须改变 source、ref、path 或配置才能选择另一份已准备 generation。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-repository-plugin",
|
||||
"description": "Restricted repository plugin format and Cordis runtime for DeepSeek Harness",
|
||||
"description": "Trusted repository package format and Cordis runtime for DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -36,6 +36,26 @@
|
||||
"@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"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Static repository-plugin preparation and prepared-manifest validation.
|
||||
* Trusted repository-package preparation and prepared-manifest validation.
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -12,23 +12,49 @@ import { parseMcpDocument } from './mcp.ts'
|
||||
export const PREPARED_ENTRY_FILENAME = 'dsh-plugin.mjs'
|
||||
/** Fixed directory containing copied static plugin assets. */
|
||||
export const PREPARED_ASSET_DIRECTORY = 'dsh-plugin-assets'
|
||||
/** Loader builtin used by every generated import-free wrapper. */
|
||||
/** 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(),
|
||||
}).strict().refine(value => value.skills.length > 0 || value.mcpServers !== undefined, {
|
||||
message: 'declare at least one skill root or mcpServers file',
|
||||
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
|
||||
@@ -38,11 +64,12 @@ const preparedConfigSchema = z.object({
|
||||
manifest: preparedManifestSchema,
|
||||
}).strict()
|
||||
|
||||
/** Static manifest embedded in the generated wrapper. */
|
||||
/** 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. */
|
||||
@@ -69,6 +96,7 @@ export function parsePreparedPluginConfig(value: unknown): PreparedPluginConfig
|
||||
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 },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -116,28 +144,50 @@ function wrapperSource(manifest: PreparedPluginManifest): string {
|
||||
...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 ctx.plugin(runtime, { baseUrl: import.meta.url, manifest })',
|
||||
" await mount(ctx, runtime, 'repository Plugin runtime', { baseUrl: import.meta.url, manifest })",
|
||||
...entryApply,
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and package one `.dsh-plugin` directory into static assets plus a fixed wrapper.
|
||||
* 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 static manifest.
|
||||
* @returns the generated prepared manifest.
|
||||
*/
|
||||
export async function prepareDshPlugin(directory: string = process.cwd()): Promise<PreparedPluginManifest> {
|
||||
const pluginDirectory = await realpath(resolve(directory))
|
||||
@@ -148,7 +198,7 @@ export async function prepareDshPlugin(directory: string = process.cwd()): Promi
|
||||
throw new Error(`failed to read DSH plugin package metadata in ${pluginDirectory}`, { cause })
|
||||
}
|
||||
const parsed = sourcePackageSchema.safeParse(packageValue)
|
||||
if (!parsed.success) throw formatZodError('invalid package.json#dsh', parsed.error)
|
||||
if (!parsed.success) throw formatZodError('invalid DSH plugin package.json', parsed.error)
|
||||
|
||||
const sourceRoot = await realpath(dirname(pluginDirectory))
|
||||
const skillSources: string[] = []
|
||||
@@ -164,11 +214,17 @@ export async function prepareDshPlugin(directory: string = process.cwd()): Promi
|
||||
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 {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Restricted repository-plugin runtime for static skills and common MCP definitions.
|
||||
* Trusted repository-package runtime for code, skills, and common MCP definitions.
|
||||
* @module @deepseek-ai/dsh-repository-plugin
|
||||
*/
|
||||
|
||||
@@ -29,6 +29,8 @@ export {
|
||||
PREPARED_ASSET_DIRECTORY,
|
||||
PREPARED_ENTRY_FILENAME,
|
||||
REPOSITORY_PLUGIN_BUILTIN,
|
||||
REPOSITORY_PLUGIN_PACKAGE_NAME,
|
||||
REPOSITORY_PLUGIN_PREPARE_COMMAND,
|
||||
prepareDshPlugin,
|
||||
type PreparedPluginManifest,
|
||||
} from './format.ts'
|
||||
|
||||
@@ -49,12 +49,14 @@ export type ResolvedMcpServer =
|
||||
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 {
|
||||
@@ -135,6 +137,7 @@ export function resolveMcpServers(document: McpDocument, environment: NodeJS.Pro
|
||||
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`)
|
||||
@@ -147,6 +150,7 @@ export function resolveMcpServers(document: McpDocument, environment: NodeJS.Pro
|
||||
serverName,
|
||||
url,
|
||||
headers: expandMap(definition.headers, environment, `mcpServers.${serverName}.headers`),
|
||||
failOnStartupError: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,12 +3,19 @@
|
||||
* @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 { PREPARED_ENTRY_FILENAME } from './format.ts'
|
||||
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`.
|
||||
@@ -22,6 +29,17 @@ export const DEFAULT_REPOSITORY_CACHE_DIRECTORY = 'repository-plugins'
|
||||
// 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)
|
||||
@@ -57,6 +75,23 @@ export function resolveRepositoryCacheDirectory(configured: string | undefined):
|
||||
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.
|
||||
@@ -73,6 +108,7 @@ export async function loadPreparedRepository(
|
||||
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
|
||||
|
||||
@@ -22,6 +22,7 @@ describe('repository plugin common .mcp.json support', () => {
|
||||
serverName: 'expo',
|
||||
url: 'https://mcp.expo.dev/mcp',
|
||||
headers: {},
|
||||
failOnStartupError: true,
|
||||
}])
|
||||
})
|
||||
|
||||
@@ -43,6 +44,7 @@ describe('repository plugin common .mcp.json support', () => {
|
||||
args: ['--endpoint', 'http://localhost:8000'],
|
||||
env: { DJ_API_URL: 'http://localhost:8000' },
|
||||
cwd: '/plugin',
|
||||
failOnStartupError: true,
|
||||
}])
|
||||
})
|
||||
|
||||
@@ -74,12 +76,14 @@ describe('repository plugin common .mcp.json support', () => {
|
||||
args: [],
|
||||
env: {},
|
||||
cwd: '/plugin',
|
||||
failOnStartupError: true,
|
||||
},
|
||||
{
|
||||
transport: 'streamable-http',
|
||||
serverName: 'remote',
|
||||
url: 'http://localhost:3000/mcp',
|
||||
headers: { Authorization: 'Bearer test-token' },
|
||||
failOnStartupError: true,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -27,10 +27,24 @@ async function temporaryDirectory(name: string): Promise<string> {
|
||||
return directory
|
||||
}
|
||||
|
||||
async function writePlugin(root: string, name: string, dsh: Record<string, unknown>): Promise<string> {
|
||||
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', dsh }, undefined, 2)}\n`)
|
||||
await writeFile(join(directory, 'package.json'), `${JSON.stringify({
|
||||
name,
|
||||
version: '0.0.0',
|
||||
devDependencies,
|
||||
scripts: { prepack },
|
||||
dsh,
|
||||
}, undefined, 2)}\n`)
|
||||
return directory
|
||||
}
|
||||
|
||||
@@ -76,6 +90,24 @@ describe('dsh-plugin-prepare', () => {
|
||||
.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({
|
||||
@@ -102,9 +134,39 @@ describe('dsh-plugin-prepare', () => {
|
||||
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 or mcpServers file')
|
||||
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'] })
|
||||
@@ -133,16 +195,21 @@ describe('dsh-plugin-prepare', () => {
|
||||
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 and without MCP assets', () => {
|
||||
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' },
|
||||
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' },
|
||||
manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json', entry: './lib/plugin.js' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -179,7 +246,90 @@ describe('prepared repository plugin Loader composition', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('delegates an MCP-only plugin to the existing client without turning connect failure into Loader failure', async () => {
|
||||
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') } },
|
||||
@@ -193,12 +343,10 @@ describe('prepared repository plugin Loader composition', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(RepositoryPlugin)
|
||||
const id = await ctx.loader.create({
|
||||
await expect(ctx.loader.create({
|
||||
name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href,
|
||||
})
|
||||
await ctx.loader.await()
|
||||
})).rejects.toThrow('initial connection or tool synchronization failed')
|
||||
expect(ctx.tools.schemas().some(tool => tool.name.startsWith('mcp__offline__'))).toBe(false)
|
||||
await ctx.loader.remove(id)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -439,12 +587,83 @@ describe('configured GitHub repository sources', () => {
|
||||
|
||||
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 () => root }, specifier))
|
||||
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', () => {
|
||||
|
||||
Reference in New Issue
Block a user