如何写一个合规插件
本篇做一个最小的 DSH 第三方插件:它通过公开的 tools/result 事件观察工具结果,使用自己的包名,通过 Bundle 的 cordis.patch.yml 装入 Profile,并说明怎样测试卸载和清理。
这里的“合规”不是法律认证,而是指扩展遵守宿主公开接口、生命周期、身份标识和信任边界。它仍然是第三方代码,不代表 DeepSeek AI 官方维护或背书。
先把本篇示例和dsh-super-injector分开:本篇写的是只调用公开 Cordis/DSH 接口的普通第三方插件;dsh-super-injector 是第三方 Bundle 加运行时注入器,会进入 Loader、Fiber、模块缓存和宿主内部表。它可以作为社区生态的研究案例,但不应把它的低层实现复制成普通插件模板,也不应把它的运行时能力写成 DSH 官方公开 API。
如果你还不知道自己的需求到底该写普通插件、Hook bridge、配置 Bundle 还是 patched fork,先看工具预算与插件责任决策卡的“五问决策卡”;确定身份后再按本篇写代码和测试。
不想一开始就读很长的 TypeScript 片段时,先完成最小插件示例与学习检查。它提供一个可直接运行的学习用第三方 Bundle:只观察 tools/result、自带 Node 单元测试和 lint,并明确写出这些检查还没有证明真实 Loader、模型或卸载行为。完成后再回到本篇的三个例子会更容易。
组件里的预览文本来自固定教学常量;它不能证明真实 Loader 的挂载顺序、真实审批界面或该插件在真实 DSH Host 中的表现。
开始前先做四个决定
- 先决定你要观察、做决策,还是提供新能力。观察优先选事件;决策选文档化的 waterfall 或 guard;提供能力选 Service、provider 或工具注册表。
- 只选择固定提交和包 README 已经说明的入口。不要因为能在调试器里看到一个私有函数,就把它当成公共 API。
- 给包使用自己的名称、仓库和版本,README 中写明支持的 DSH 版本或 commit 范围。不要使用
@deepseek-ai/*等上游命名空间伪装身份。 - 列出插件拥有的资源:监听器、工具、服务、timer、文件 watcher、子进程、网络连接和缓存文件。每一项都必须有可验证的清理路径。
第一个例子:只观察工具结果
这个插件不改变工具行为,只在工具结果完成后打印一行脱敏、限长的预览。它适合作为第一步,因为它没有权限决策,也不需要修改 agent loop。tools/result 只提供结果观察能力,事件本身不会自动授予文件、网络或凭据权限;但插件仍运行在宿主进程和安装环境的权限范围内,安装前仍要检查它的依赖、文件访问、网络访问和凭据使用。
先用一段可以在浏览器里直接运行的 JavaScript 看清骨架(真实实现是 TypeScript,事件名和生命周期一致):
// 教学版事件总线:只实现 tools/result 的广播。
const bus = {
handlers: [],
on(name, fn) { this.handlers.push(fn); return () => { const i = this.handlers.indexOf(fn); if (i >= 0) this.handlers.splice(i, 1) } },
emit(resultText) { for (const handler of [...this.handlers]) handler({ name: 'tools/result', resultText }) },
}
function createObserver({ maxLength = 20 } = {}) {
const plugin = { previews: [], effects: [] }
plugin.setup = () => {
plugin.effects.push(bus.on('tools/result', (event) => {
const text = String(event.resultText)
const preview = text.slice(0, maxLength)
plugin.previews.push(preview)
console.log((text.length > maxLength ? '截断预览:' : '预览:') + preview)
}))
console.log('已订阅 tools/result')
}
plugin.unload = () => {
plugin.effects.splice(0).forEach(off => off())
console.log('已卸载')
}
return plugin
}
const observer = createObserver({ maxLength: 12 })
observer.setup()
bus.emit('这是一段会被截断的工具结果文本')
observer.unload()
bus.emit('卸载后这条不应产生输出')
console.log('previews 总数:', observer.previews.length)把 maxLength 改成 6 再运行一次;把 observer.setup() 注释掉再运行——挂载了但没订阅,广播从它面前经过。
目录
dsh-yourname-study-plugin/
├── package.json
├── index.js
└── cordis.patch.yml插件入口 index.js
export const name = 'dsh-yourname-study-plugin'
// `tools/result` 由 dsh-tools 声明和发出;`inject` 声明插件依赖 tools 服务。
export const inject = ['tools']
function previewResult(result) {
return result.content
.filter(block => block.type === 'text')
.slice(0, 3)
.map(block => block.text.slice(0, 160).replace(/[\r\n]+/g, ' '))
}
export function apply(ctx) {
ctx.on('tools/result', (exec, result) => {
const preview = previewResult(result)
console.log(`[study-plugin] ${exec.name} -> ${JSON.stringify(preview)}`)
})
}这里的 ctx.on() 是 Cordis 的公开注册方法。监听器属于当前插件 Fiber,卸载 Fiber 时会自动移除;插件不需要访问 ctx.events._hooks,也不需要自己从数组中删除监听器。示例只保留三段、每段 160 个字符以内的文本预览;真实插件还应根据业务做字段白名单、凭据脱敏和日志保留期限,不能把完整文件内容、工具参数或网络返回直接写进日志。
inject 声明的是硬依赖:Loader 可以并发声明多个插件,Cordis 会等 tools provider 变为 ACTIVE 再运行本插件。例如 inject: ['tools'] 的插件,其 apply 一定晚于 tools 服务就绪;可见性和生命周期由这条依赖关系决定,真正的服务注册仍由 provider 完成。
Bundle 配置层 cordis.patch.yml
- insert:
- id: study-plugin
name: dsh-yourname-study-plugin这份 YAML 只负责把包加入插件树。它不是修改 DSH TypeScript 源码的 patch,而是配置组合层;用户可以在自己的 Profile 中覆盖或移除这一行。
包清单 package.json
{
"name": "dsh-yourname-study-plugin",
"version": "0.1.0",
"type": "module",
"main": "index.js",
"files": ["index.js", "cordis.patch.yml"],
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
}
}
}dsh.bundle.patch 把包标记为带有配置层、可装配进 DSH 的 Bundle。Bundle 的身份仍由包发布者决定;拥有这个字段不会让它变成 DeepSeek 官方包。
上面的 JavaScript 示例是可以直接看懂的最小骨架;下面的 TypeScript 片段主要展示公开 API 的类型形状,不是复制后无需配置即可发布的完整项目。
实际发布时要把 @deepseek-ai/cordis、@deepseek-ai/dsh-tools 等依赖声明为与目标 DSH 版本相容的 peerDependencies 或明确的运行时依赖,补上 TypeScript 构建配置和 lockfile;不能把 workspace:* 之类只在官方 monorepo 内可解析的版本号直接发布给用户。
第二个例子:注册自己的工具
如果你的目标是让模型能够调用新能力,使用 ctx.tools.register(),不要直接向工具 Map 写入。下面的代码沿用官方工具教程的 API 名称,展示结构而不是完整产品功能。
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'dsh-yourname-greet-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'study_greet',
description: 'Return a greeting for a supplied name.',
parameters: {
name: {
type: 'string',
required: true,
description: 'The name to greet.',
},
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
return `Hello, ${args.name}!`
},
}))
}ctx.tools.register() 返回的清理函数会被当前 Fiber 追踪,工具在插件卸载后不再出现在该作用域。工具的参数校验、结果 schema、模型展示和执行流水线由 dsh-tools 负责,插件只提供定义和执行逻辑。
工具扩展点不是权限边界。若功能涉及文件、网络、shell、凭据或子进程,应继续遵守对应 provider 的权限和审批规则,不能因为工具已经注册就默认获得更高权限。
第三个例子:提供一个可替换服务
当多个插件要共享一项能力,并且测试或部署需要替换实现时,才值得定义自己的服务。服务名称使用有辨识度的前缀,避免和 DSH 已占用的名称冲突。
import { Service, type Context } from '@deepseek-ai/cordis'
declare module '@deepseek-ai/cordis' {
interface Context {
studyGreeting: StudyGreetingService
}
}
export class StudyGreetingService extends Service {
constructor(ctx: Context) {
super(ctx, 'studyGreeting')
}
greet(name: string) {
return `Hello, ${name}!`
}
}
export const name = 'dsh-yourname-greeting-service'
export function apply(ctx: Context) {
ctx.plugin(StudyGreetingService)
}消费方声明 inject: ['studyGreeting'] 后使用 ctx.studyGreeting。运行时注册由 Service 完成,TypeScript 声明合并只负责让消费方得到类型;两者缺一不可时,应在同一个包或清楚关联的接口包中维护。
普通插件 Context 和 Agent scoped Context 也要分清:在普通 Context 上注册的工具或服务通常对该 Context 的子树可见;通过 agent.ctx 注册的能力可以只对该 Agent 生效。作用域只说明“谁能看到这项能力”,不等于安全权限边界,文件、网络、Shell 和凭据权限仍要由对应 provider 和审批策略决定。
通过 Profile 安装
在已经安装 DSH CLI 的环境中,可以把本地插件加入一个 Profile:
dsh plugin --profile study add ./dsh-yourname-study-plugin
dsh --profile study --dump-config
dsh --profile study--dump-config 用来检查 Bundle 层是否真的被组合进去。看到配置行不等于插件已经成功运行,还要看依赖是否就绪、插件是否进入 ACTIVE,以及工具或事件是否产生预期结果。
移除时使用:
dsh plugin --profile study remove dsh-yourname-study-plugin如果你从 DSH 源码 checkout 运行,把命令中的 dsh 换成 pnpm dsh。官方 CLI 会把 Profile、Bundle、home patch 和 --patch overlay 按固定顺序组合;插件作者不应要求用户复制或修改 DSH 核心源码。
从 GitHub 安装时要锁定 commit:
dsh plugin --profile study add github:yourname/dsh-yourname-study-plugin#<commit-sha>Git 依赖可能在安装时执行 prepare 构建脚本。这个过程会在用户机器上执行第三方代码,不属于 agent 沙箱;只对可信来源授权,并优先提供已经构建好的 npm 包或 tarball。
生命周期和卸载
Cordis 已经替你管理的、并且已经登记到当前生命周期的注册,不要重复写清理逻辑。ctx.on()、ctx.tools.register()、ctx.provide() 和 ctx.plugin() 都会把相应 disposer 挂到当前 Fiber;Fiber 不会自动发现插件私下创建的 timer、watcher、连接或子进程。
对于 timer、watcher、连接或子进程这类 Cordis 不认识的资源,使用 ctx.effect():
export function apply(ctx: Context) {
ctx.effect(() => {
const timer = setInterval(() => {
console.log('plugin heartbeat')
}, 1000)
return () => clearInterval(timer)
})
}如果清理步骤有先后关系,把它们放进同一个 disposer 并在其中按顺序等待。不要把一个资源交给全局数组,再希望某个未来的 patch 能记得删除它。
子插件也属于父插件的所有权范围:const fiber = ctx.plugin(child) 后,父 Fiber 卸载会递归处理子 Fiber;需要提前结束时可以 await fiber.dispose()。
测试不能只看“加载成功”
| 检查层 | 要证明的事实 | 例子 |
|---|---|---|
| 静态检查 | 入口、导出、类型和包清单能构建 | pnpm run typecheck、pnpm run lint |
| 单元测试 | 监听器、工具参数和错误处理符合预期 | 用固定输入调用纯函数或最小 Context |
| 组合测试 | Profile/Loader 能找到包并按依赖挂载 | dsh --profile study --dump-config 后启动实际树 |
| 行为测试 | 真实工具流水线或事件确实触发 | 断言 tools/result、工具返回内容或 Session 记录 |
| 卸载测试 | 监听器、工具、timer、子进程和文件 watcher 都消失 | await fiber.dispose() 后再次触发并确认没有旧输出 |
| 信任检查 | 包名、来源、权限、依赖和版本声明清楚 | 自有 namespace、锁 commit、README 写风险 |
根据 DSH 固定提交的官方测试策略,面向产品用户的插件需要使用真实组合测试,而不是只手工创建一个 Context 并断言某个函数被调用。
最小 Context 测试适合快速定位;Loader 启动的组合测试才验证了 package manifest、配置层和实际依赖关系。库内复用、不会单独进入产品 Bundle 的模块,应按自己的发布边界写清测试要求。
没有 API key 时,可以验证插件加载、工具 schema、事件和卸载;不能把没有真实模型请求的结果写成“已验证 DeepSeek 模型调用”。没有浏览器 E2E 时,也不能写成“Web UI 交互已完整验证”。
插件 README 应该写什么
一个让别人敢安装的 README 至少应回答:
- 这是官方维护、社区维护还是个人实验包?
- 支持哪些 DSH 版本或固定 commit?使用了哪些服务、事件和工具权限?
- 它会读取哪些文件、环境变量、凭据、网络地址或命令?是否启动子进程?
- 如何安装、配置、检查已装配层、卸载和恢复?
- 每个注册和外部资源在何时清理?热重载或依赖消失时会发生什么?
- 有哪些单元、组合、行为和卸载测试?哪些结果仍未在真实 API 或真实浏览器中验证?
- 包名、仓库、许可证、依赖版本和发布构建来自哪里?是否锁定 Git commit?
如果 README 只写“支持 hook、功能强大、兼容官方”,却不写接入点、权限、版本和卸载方式,读者无法区分真正的插件与运行时补丁。
没有公开 hook 时怎么办
第一步是检查相邻的公开能力:同一包是否有服务方法、事件、工具注册表或配置 overlay。很多“缺少 hook”的需求其实可以通过观察事件或增加独立 provider 完成。
第二步是查看固定提交中的类型、README、测试和官方扩展手册。只有源码里出现私有函数,不代表上游承诺它的名字、参数和调用时序不会变。
第三步是向上游提出一个明确的扩展需求:说明要观察什么、需要返回什么、生命周期如何清理、是否影响 Session 可恢复性,以及希望哪些测试锁定行为。
如果必须改变核心行为,就把它作为 fork 或 patched build 发布,记录上游 SHA、补丁文件、构建命令和同步成本;不要把 fork 的内部 hook 包装成“无需 patch 的官方插件”。
五种方案的准确叫法
| 实际做法 | 推荐名称 | 主要责任 |
|---|---|---|
只写 apply(ctx),调用公开 service/event API | 第三方 Cordis 插件 | 遵守公开 API、Fiber 清理和版本范围 |
插件包带 dsh.bundle.patch,通过 Profile 装配 | 第三方 DSH Bundle / 组合包 | 维护 patch 层、manifest 和安装说明 |
| 修改 DSH 源码并重新构建 | patched fork / 私有 fork | 维护源码差异、同步上游和完整构建 |
| 改私有 Map、替换导出或向进程塞代码 | registry patch / monkey patch / 运行时注入 | 承担脆弱性、权限和回滚风险,不得冒称官方插件 |
| 把外部 hook 协议翻译成 DSH 事件 | hook bridge / 兼容层 | 维护协议版本、失败处理和身份说明 |
最终检查清单
- [ ] 插件包有自己的名称、仓库、版本和许可证。
- [ ] 所有接入点都能在 DSH 固定提交的类型或官方文档中找到。
- [ ] 没有访问私有 registry、私有 Map、构建产物内部路径或未声明的模块导出。
- [ ]
ctx.on、ctx.tools.register、ctx.provide和自定义资源都有 Fiber 所有权。 - [ ] Profile 能通过
--dump-config看见组合层,卸载命令能移除包和配置行。 - [ ] 如果插件会进入产品 Bundle 或面向用户安装,有至少一个真实 Loader 组合测试和一个卸载测试;仅供库内复用的模块按自己的发布边界记录相应测试。
- [ ] README 写清权限、数据、网络、构建脚本和真实验证边界。
- [ ] 没有使用官方包名、官方 namespace 或容易造成官方背书错觉的标识。
- [ ] GitHub 安装示例锁定 commit,npm 或 tarball 安装说明给出完整性和来源检查建议。