返回 Skill 列表
extension
分类: 开发与工程无需 API Key

dsh-plugin-dev

高精度 DeepSeek Harness (dsh) 插件开发技能。 包含完整的 API 契约、类型签名、配置 Schema 写法及事件处理范式。 AI 应严格遵循此 Skills 中的代码模板与约束,禁止臆造 API。

person作者: awol2005exhubModelScope

DeepSeek Harness Plugin Development Skill

You are an expert plugin developer for the DeepSeek Harness (dsh) ecosystem. When asked to create, modify, or debug a dsh plugin, you MUST follow the specifications below precisely. Never invent APIs, types, or patterns not defined here.

Core Principles

  • Everything in dsh is a plugin: adapters, tools, loggers, and the agent loop itself.
  • Plugins interact exclusively through Context (ctx). Never import or call other plugins directly.
  • All registrations MUST be reversible. Use ctx.effect(), ctx.on(), or ctx.plugin() so cleanup happens automatically on HMR/unload.
  • Load order is determined by inject dependencies, not file order.
  • Function plugins MUST use named exports. Never use export default.

Plugin Structure Contract

Every plugin module must export exactly these identifiers:

| Export | Required | Type | Purpose | |--------|----------|------|---------| | name | Yes | string | Unique plugin identifier, kebab-case | | apply | Yes | (ctx: Context, config?: Config) => void \| Promise<void> | Plugin entry point | | inject | No | readonly string[] | Required service dependencies | | Config | No | z.ZodType<Config> | Declarative config schema using @deepseek-ai/schemastery |

Correct Plugin Skeleton

import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'

export const name = 'my-plugin'
export const inject = ['tools'] as const

export interface Config {
  apiKey: string
  timeout?: number
}
export const Config: z<Config> = z.object({
  apiKey: z.string().required(),
  timeout: z.number().default(30000),
})

export function apply(ctx: Context, config: Config) {
  // Plugin logic here
}

Tool Development Contract

Import defineTool from @deepseek-ai/dsh-tools. The execute function receives pre-validated args and an execution context.

Key Rules

  1. Args are already validated. Do not re-validate inside execute.
  2. Return only what output.schema defines. Returning a raw string or mismatched object will fail validation.
  3. Object output schemas MUST set additionalProperties: false. Without it, the inferred type widens to Record<string, JsonValue> and the execute return value fails type checking. Example: z.object({ results: z.array(z.string()) }, { additionalProperties: false }) — for the schema-level form, pass it as a property on the object schema.
  4. Respect cancellation. Check exec.signal?.aborted at the start and between async steps.
  5. Errors = isError. Throw Error for failures; do not return error objects.
  6. Never console.log. Use ctx.logger.info/warn/error.

Tool Template

import { defineTool } from '@deepseek-ai/dsh-tools'

ctx.tools.register(defineTool({
  name: 'search-docs',
  description: 'Search internal documentation by keyword',
  args: z.object({ query: z.string().required() }),
  output: {
    schema: z.object({ results: z.array(z.string()) }).additionalProperties(false),
    render: (v) => `Found ${v.results.length} results`,
  },
  async execute(args, exec) {
    if (exec.signal?.aborted) throw new Error('Cancelled')
    const results = await searchInternal(args.query)
    return { results }
  },
}))

Event Hook Patterns

Hooks have three distinct signatures. Using the wrong one breaks the pipeline.

Waterfall Hooks (MUST call next())

Used for interception, permission checks, and request modification. You must either return a short-circuit value OR call return next().

// Permission guard example
ctx.on('tools/pre-execute', async (exec, next) => {
  if (!hasPermission(exec.tool.name)) {
    return { kind: 'deny', reason: 'Insufficient permissions' }
  }
  return next() // ← CRITICAL: forgetting this hangs the pipeline
})

Common waterfall events: tools/pre-execute, agent/request, tools/execute.

Serial Hooks (No next())

Used for side effects that don't modify flow.

ctx.on('agent/turn-stopping', async ({ reason }) => {
  ctx.logger.info(`Turn stopping: ${reason}`)
})

Emit Hooks (Read-only observation)

Used for logging, metrics, and UI updates. Synchronous or async, no next().

ctx.on('tools/result', ({ tool, result, duration }) => {
  metrics.record(tool, duration)
})

Anti-Patterns (Auto-Correct These)

When generating or reviewing code, actively detect and fix these issues:

| ❌ Wrong | ✅ Correct | Why | |----------|-----------|-----| | export default function apply | export function apply | Default export loses inject metadata | | Bare setInterval(...) | Wrap in ctx.effect(() => { const t = setInterval(...); return () => clearInterval(t) }) | Leaks on HMR/unload | | Return string from execute | Return object matching output.schema | Schema validation will reject primitives | | Waterfall hook without next() | Always return next() or explicit short-circuit | Pipeline hangs indefinitely | | Optional service in inject | Use ctx.get('name') at runtime | Missing optional dep blocks plugin load | | Deep merge assumption in patch | Patch replaces entire config by id | Partial patches silently drop fields |

Configuration & Patching

  • Config schemas use @deepseek-ai/schemastery (Zod-compatible).
  • Patches replace the entire config object for a given plugin id. They do NOT deep merge.
  • !!js expressions are ONLY allowed in plugin.config values and disabled fields.
  • Environment variables: !!js "process.env.MY_VAR"

Debugging Workflow (Independent Project)

When developing outside the main harness repo:

  1. Declare the bundle layer in package.json"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }. A bare string like "bundle": "headless" is NOT recognized by the CLI; only dsh.bundle.patch !== undefined qualifies (apps/cli/src/plugin.ts, reconcilePlugins). Provide a root cordis.patch.yml that inserts the plugin row, e.g.:
    - insert:
        - id: my-plugin
          name: my-plugin
    
  2. Install into the web profile: npx @deepseek-ai/dsh plugin --profile web add . (installs as a link: dependency of ~/.dsh/profiles/web). Restart the host afterwards — the plugin set is scanned at boot and cached.
  3. Verify config tree: npx @deepseek-ai/dsh --profile web --dump-config
  4. Test with task: npx @deepseek-ai/dsh --profile web "test my plugin"
  5. Apply temporary override: npx @deepseek-ai/dsh --profile web --patch ./overlay.yml "task"
  6. Type check: tsc --noEmit

Always confirm the plugin appears in --dump-config output before debugging runtime behavior.

Web Client Bundle Contract (Browser Half)

A plugin can also ship browser-side JS without forking the harness repo. All paths below are verified against packages/client/modules/src/index.ts.

  1. Declaration (in package.json):
    • "client": { "platform": "web" } inside the existing dsh field;
    • exports["./client"]: string or { default: "./lib/client.js" };
    • exports["./package.json"]: "./package.json" is MANDATORY. The host resolves <pkg>/package.json with require.resolve to read metadata; if Node throws ERR_PACKAGE_PATH_NOT_EXPORTED, the package is permanently marked as a non-client row — Node tools keep working while the Web buttons 404 silently.
  2. Bundle format ("lazy CJS closure factory", identical to packages/client/tsdown.client.ts banner/intro/footer). Plain tsc output does NOT qualify — it appends export {} which breaks classic-script parsing:
    window.__ModuleLoader__.load({ id: "<pkg>", factory: (require) => {
    var module = { exports: {} }; var exports = module.exports;
    /* bundle body: no ESM syntax; assign exports via module.exports */
    return module.exports; } });
    
  3. Registration id equals the package name; the factory return value ({ name, inject: [], apply }) becomes the plugin module table used to build the browser fiber. Set inject: [] unless the client actually needs host services.
  4. Serving: host composes rows into window.__DSH_BOOT__ and serves each bundle at /plugins/<id>/client.js?rev=<content-hash>. Diagnose via DevTools: missing entry in window.__DSH_BOOT__.entries → host-side scan failed (usually the ./package.json issue); entry present but console errors → bundle format/registration bug.

The reference implementation lives in this repo: src/client.ts + scripts/wrap-client.mjs.


HTTP 扩展点(鉴权 / 会话隔离 / 拦截 /api / 注入前端)

以下结论均核对过 deepseek-harness/packages/ 源码(harness 版本见各文件路径)。当需求涉及 识别调用者身份拦截既有 /api 请求 时,上面的 connection.rpc.handle 不够用 —— 用这一节。

一、能挂的三个点

1. ctx.webServer —— 唯一能拿到 HTTP 头的入口

// packages/host/webserver/src/index.ts
register({ kind: 'exact' | 'prefix', path, handler: (req, res) => void | Promise<void> }) => disposer
registerUpgrade({ path, handler }) => disposer   // 同路径重复注册直接抛错
registerFallback(handler) => disposer            // 唯一席位,已被 SPA dist 服务占用
tapIndex((html: string) => string) => disposer   // index.html 原文变换

匹配顺序是 exact 表优先,其次最长前缀WebServer.match)。connection 插件把 /api 注册成 prefix,所以:

ctx.webServer.register({ kind: 'exact', path: '/api/session.list', handler })

合法压过 /api,拿到原生 IncomingMessage(可读 Cookie、Header、Body)。

tapIndex 在所有结构化注入行之后应用,所以插在 <body> 之后的脚本排在所有注入行之后、应用自带 <script> 之前。

2. ctx.apiProxy —— 同进程取真实数据,不自环

域 → 方法(见 packages/host/apiproxy/src/api/index.ts):sessions / subagents / host / workspace / skills / agentPresets / goals / settings / credentials / llm / downloads / respond

const api = ctx.get('apiProxy')
await api.sessions.list({ rpcId, payload: {} })   // → { rpcId, result }

result 形如 { ok: true, value } | { ok: false, error }

⚠ 方法前缀 → 域名的映射不规则,必须查表,不能靠加 s 推导

| 方法前缀 | 域 | | --- | --- | | session | sessions | | subagent | subagents | | agentPreset | agentPresets | | goal | goals | | host / workspace / settings / credentials / llm | 同名 |

切分用 method.lastIndexOf('.')agentPreset.openDocument 只有最后一个点是分隔)。写错的表现是运行时 api method xxx unavailable,不是编译错误。

官方实现不自行校验 payload(校验在 toFetchHandlerUNARY_ROUTES 表里),直接调方法时要自己保证 payload 形状。

3. ctx.connection.rpc.handle —— 拿不到请求头,别用来做鉴权

// packages/client/connection/src/rpc.ts
type ConnectionRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal) => Promise<RpcResult>

没有 req读不到 Cookie,识别不了调用者。只适合无鉴权的功能端点。另有 rpc.intercept('/api', matcher, handler, options) 可接管 /api 上的端点,但同样是这个签名 —— 一样拿不到头。

二、地基限制(架构决策前必读)

  1. isTrustedApiRequest 不是鉴权层。 源码注释原文:"this fence is not an auth layer"。它只防 DNS rebinding(验 Host)和跨站(验 Origin / Sec-Fetch-Site)。任何"登录"都必须自己实现。
  2. WebSocket 事件流无法在进程内隔离。 /api/events.mux/api/events.host 是 upgrade 路由,registerUpgrade 重复注册抛错 → 插件接管不了;且官方以 空 payload 打开 mux 流(api.events.mux({ rpcId, payload: {} })),不带任何客户端身份 → 每个浏览器都收到全量会话事件。要做真正的事件隔离,只有另起网关进程做反向代理 + 逐帧过滤。这是"纯插件方案"的能力天花板,评估需求时必须先讲清。
  3. registerFallback 只有一个席位,已被 SPA dist 服务占用,插件抢不到。
  4. 无法把请求转交给同路径的官方 handler。 插件 exact 注册 /api/session.export 后,内部再请求该路径只会命中自己(自环)。所以 session.export 这类 GET + query 参数、不走 JSON 信封的端点只能自己重新实现或放弃。
  5. DSH_HOME 环境变量可整体重定向数据根packages/util/home-paths:configured > $DSH_HOME > ~/.dsh)。这是"每用户独立数据目录"、进而"每用户独立进程"方案的支点。

三、错误码

RpcError闭合判别联合(packages/host/apiproxy/src/api/rpc.schema.tsrpcErrorSchema),没有 unauthorized / forbidden。业务层拒绝用 code: 'bad-request', details: { issues: [] }不要用 internal(部分客户端会触发重试)。HTTP 状态保持 200 —— 状态只表达载体层,业务错误一律走 200 + 错误分支。

四、踩坑

  • 切域名用 lastIndexOf('.') 并查映射表(见上)。
  • cordisctx.get(name) 返回代理对象:自有字段可枚举,原型上的属性不在 Object.keys 里。诊断时别只看 keys。
  • 切换数据源要关掉旧连接:SQLite 会一直锁文件,Windows 上 EBUSY,删文件重建都会失败。
  • 测试里假 res 必须是 EventEmitter(补 on/off):invoke 会在 res 上挂 close 监听传取消信号,缺了就 500 ...is not a function
  • 测试里假 req 别用 Object.assign(Readable.from(...), {...}):会覆盖 Readableon/off,令 for await (const chunk of req) 永久挂起。用 class X extends Readable
  • 浏览器 bundle 不能含 TS 特有语法enum / namespace / 参数属性 / satisfies / declare global),tsc 会产出无法直接执行的语句。用 interface + as unknown as X 收窄 DOM 元素类型。
  • systemPrompt 分节文本会被强制 {{variable}} 插值,任何 {{...}} 都会令装配抛错 malformed prompt variable reference。注入用户自由文本前要转义。

五、验证手段(无浏览器也能测)

用最小假 cordis 上下文驱动真实插件,比 --dump-config 更能验证行为:

const ctx = { logger, get: n => ctx.services[n], services: { webServer, apiProxy }, effect: fn => effects.push(fn) }
  • webServerregister 存进 Map 并暴露 request({ method, path, headers, body })tapIndex 收集变换。
  • apiProxy:只实现被影子化的方法,返回 { rpcId, result }
  • 清理时若 rmSyncEBUSY,说明有连接没释放 —— 一个免费的泄漏检测器。
  • 清理失败会掩盖测试本身的异常,把清理放在 finally 里单独 try/catch 报告

参考路径(harness 源码,只读)

  • packages/host/webserver/src/{index.ts,injections.ts} —— 路由与注入
  • packages/host/apiproxy/src/{index.ts,api-proxy.ts,fetch/handler.ts,api/rpc-map.ts}
  • packages/client/connection/src/{index.ts,rpc-host.ts,rpc.ts,websocket-downlink.ts}
  • packages/client/connection/src/api-request-trust.ts —— 信任栅栏(明确非鉴权)
  • packages/util/home-paths/src/index.ts —— DSH_HOME 解析