← Back to skills
extension
Category: Development & EngineeringAPI key requirement unconfirmed

dsh-plugin-dev

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

personAuthor: 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.
  • dsh ≥ 0.1.7 没有 ctx.settings.register():插件配置即「导出 Config schema + profile 条目 config」,见 Configuration & Patching。

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> | Config schema via @deepseek-ai/schemastery; since 0.1.7 this IS the plugin's config carrier (.volatile() fields → settings form + hot reload) |

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)
})

⚠ 实时流式增量通道 = agent/assistant-stream,不是 session/event。 写「把 Agent 输出转发到外部 IM(企微/飞书等)」的插件时:

  • session/event 总线在生产代码中零 assistant/chunk emit 点,只承载耐久事件 assistant/message (干净终稿)与 turn/end(reason.kind==='error' 带错误原因)。监听它拿不到逐 token 增量。
  • 真正的实时增量通道是 agent/assistant-stream(payload: { agent, frame }), frame.type ∈ start|chunk|end;frame.chunk.type 为 reasoning-delta/text-delta/block-* /usage/finish。按 payload.agent 对象引用过滤到本会话 agent(cordis 事件全局广播,挂任意 ctx 都能收到)。
  • end 帧的 frame.outcome:committed + eventType==='assistant/message' = 本次 attempt 产出了一条消息 (★多工具循环里「只调了工具、还没有文本答案」的中间 attempt 也是它★,因为工具调用本身就在该消息里); committed + eventType==='assistant/attempt' = 本次尝试未产出消息(LLM 失败路径);abandoned = 罕见持久化失败。
  • 【最容易踩】agent/assistant-stream 的 end 帧绝不能用作回合结束信号。 end 每次 attempt 都发、 早于最终答案;若在此收尾(并按 cleanText 判空),会在「模型还在调下一个工具」时提前切断、误报 「已调用工具但未返回文本结果」,并丢掉后续 attempt 的真实答案——表现就是「插件报了警告,但 dsh 会话其实还在继续」。 唯一可靠的回合终点是 session/event 的 turn/end(位于 harness agent-loop/src/agent.ts 外层 turn() 的 finally,整段工具循环跑完之后才 append,每个逻辑回合仅一次)。正确分层:流式监听只转发 chunk (if (frame.type !== 'chunk') return,start/end 一律不收尾);收尾全部交给 turn/end (reason.kind==='error' → 错误文案;非 error → 收尾内容选择器),另加超时兜底。见 dsh-wecom 的 bridge.ts。
  • 绝不要用 frame.turn 做过滤或去重:生产帧的 frame.turn 恒为 undefined(AssistantStreamAttempt 构造时未传入), 且 start 帧不一定到达监听器。一旦在 chunk/end 处理里写 if (frame.turn !== targetTurn) return 这种守卫, 所有增量帧和成功 end 帧都会被静默丢弃 → 表现就是「选了流式也看不到思考过程,只剩终稿」。 直接按 payload.agent === agent 过滤即可(cordis 全局广播 + 每会话串行,无需 turn 维度)。
  • append 是增量累加,不是整段替换:WsClient.append(delta) 内部 buf += delta 后发全量 buf。 转发时务必传单块 delta(chunk.text),不要传自己累积的全量 liveBuf(会双重叠加成巨型内容)。 想从「思考阶段」切到「答案阶段」时调用 reset(content) 整段替换已展示内容,而非继续 append。
  • 保活心跳必须「无条件」重发当前内容(buf || placeholder):外部 IM(企微/飞书)的流式消息在长空闲期 (典型:Agent 调用工具执行几十秒~几分钟)会被服务端按空闲超时掐断。一旦把 keepAlive 写成「仅当 buf 为空才发占位帧」, 思考首帧 append 之后 buf 即非空、心跳转静默,工具调用的长空窗里流就死了——工具之后的答案帧全被丢弃, 表现就是「思考过程能看到、工具调用后什么都不出」。改为每几秒重发同一份内容(服务端只保持该流式消息、无视觉变化)即可撑过空窗。
  • 给工具调用加可见标记:tool-call-delta 带 name(工具名),block-start 带 blockType。在长工具间隙把 「⏳ 正在调用工具:<name>…」透出到直播内容,避免用户以为卡死;答案到达后由 reset 整体替换为答案。
  • 收尾绝不能回退成工具标记:当一轮真正结束(turn/end)却没有任何文本答案(cleanText 为空且 nText===0, 典型是工具失败/模型中断)时,finish 的兜底绝不能用 buf(里面还装着「⏳ 正在调用工具…」标记), 否则用户看到的就是「卡在工具标记上、像断了」。收尾选择器应是:干净终稿 > 已流式答案(buf) > 明确提示 (「⚠️ 已调用工具(xxx)但未返回文本结果」/「本次未生成回复内容」)。工具标记只应出现在流式进行中,绝不该成为终稿。

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 (0.1.7 配置契约)

⚠ dsh ≥ 0.1.7 已移除 ctx.settings.register()。 插件配置的唯一载体是「插件导出的 Config schema + profile 条目 config」:bundle patch 插入的条目 id(如 id: wecom)就是该插件的配置命名空间;旧 ~/.dsh/settings.yaml 中同名段会被 harness 一次性并入该条目(可写迁移脚本辅助升级)。

.volatile() 字段 = 可热改配置

  • 只有标记 .volatile() 的字段会进入「设置 → 插件」表单并允许被热改。
  • 解析后该字段是 cosmokit 的 Volatile<T> 稳定引用(含 .get())。设置页改写时 loader 原地更新引用里的值、不重挂插件,然后在本插件的 ctx 上广播 loader/volatile-update。
  • 引用不变、值在变:禁止缓存 apply 时的配置,每次启停前现读现用。解包器(新 dsh 引用 / 旧 dsh 与测试替身是普通值,都能跑):
type MaybeVolatile<T> = T | { get(): T }
function unwrap<T>(v: MaybeVolatile<T> | undefined | null): T | undefined {
  if (v === undefined || v === null) return undefined
  const ref = v as { get(): T }
  if (typeof ref.get === 'function') {
    try { return ref.get() } catch { return undefined } // 引用被替换/未提交 → 回退
  }
  return v as T
}

热更新监听:loader/volatile-update

// 事件名由 @deepseek-ai/cordis-plugin-loader 增补;插件可不依赖该包,用宽松调用避免类型耦合。
const onEvent = (ctx as unknown as {
  on?: (event: string, listener: (...args: unknown[]) => void) => unknown
}).on
onEvent?.call(ctx, 'loader/volatile-update', () => {
  restartServices(readSettings(config))   // 重读 → 热重启/重装
})
  • 监听随 fiber 卸载自动反注册,无需手动清理。
  • 若改写被 loader 判定为「普通配置变更」(改了非 volatile 字段),插件会整体重挂——apply 重新执行,同样拿到新配置;两条路径都要覆盖。
  • 装配动作应收进可重入的 start()(首装 / 热改 / 宿主服务迟到就绪共用);真正长期的进程级状态(cookie 签名密钥、会话索引)留在 apply 作用域,避免热改让已登录用户失效。

浏览器侧配置读写

  • 首选宿主表单服务 ctx.configForms(@deepseek-ai/dsh-client-ui-settings provide):get(ns) 返回表单控制器,getSnapshot() → { status, value, revision, writable },mutate(ops, revision) 走远端 settings.mutate 落盘(volatile 字段写进 profile patch,宿主据此热更新)。
  • 退路是插件自建 RPC 端点(connection.rpc.call);configForms / slots 未在 fiber 声明时属性访问会抛 cannot get property ... without inject,务必 ctx.get + 运行时探测,别写死在启动路径。
  • secret 用 z.string().role('secret'),值永不过线——判断「是否已配置」要看描述镜像的 secrets[]。
  • 配置面板可挂设置页插槽 settings.plugins.tab,命名空间必须与条目 id 一致。

其他规则(长期不变)

  • 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"

Session 格式 v4 · 消息源 kind 声明(0.1.7)

dsh 的 session 格式 v4 取消了共享兜底 source.kind: 'plugin' 包装:source.kind 为 'plugin' 或缺失时装配抛 format v4 message requires a producer-owned source kind。每个生产者必须先声明自己的 kind,再在消息里引用:

// src/source.ts —— 必须被业务模块真实 import(`import type {}` 会擦除导致 declare module 不生效)
declare module '@deepseek-ai/dsh-llm' {
  interface MessageSourceMap {
    wecom: {
      readonly kind: 'wecom'
      readonly chatId: string
      readonly chatType: 1 | 2
      readonly msgId: string
    } & ContextFormed
  }
}
// 产生消息时携带自定义 source
handle.agent.followup(createUserMessage({
  content: [{ type: 'text', text: content }],
  source: { kind: 'wecom', chatId, chatType, msgId },
}))

同一版本还对消息模型做了收紧(dsh-tools 0.1.7-rc.2):

  • 工具结果是一等的 tool 角色消息,tool-result 内容块已移除(0.0.x 的嵌套 content 展开只作为回退分支保留)。
  • Session.events getter 已移除:读事件日志用 session.snapshotEvents()(老版本回退 session.events)。
  • 上下文/注入消息判断基于 source.kind(如 user-approval 这类自定义 kind),不再是旧版 plugin 标识。

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 不够用 —— 用这一节。⚠ 0.1.5 起插件 RPC 的唯一正解是 connection.fetch.register()(见第四节),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(校验在 toFetchHandler 的 UNARY_ROUTES 表里),直接调方法时要自己保证 payload 形状。

2'. ctx.typertGateway —— 0.1.5+ typert/gateway 架构的正式数据闸门

connection.fetch 的 exact 路由不经过 typert gateway,若影子路由的处理器要拿真实业务数据,用 ctx.typertGateway.invoke 调 harness 内部 api(与 apiProxy 等价但面向新架构;dsh-cas 用它转发 session/*)。宽接口只依赖 invoke:

interface GatewayInvoke {
  invoke: (request: {
    namespace: string
    method: string
    args: Record<string, unknown>
    signal?: AbortSignal
  }) => Promise<unknown>
}
  • 返回原始业务值;业务 RemoteError 会 throw,要自己 catch 后回 { ok: false, error } 信封。
  • 通过 ctx.inject(['connection', 'typertGateway']) 或 ctx.get 取得,缺省时应降级(不启用隔离),别阻塞插件加载。

3. ctx.connection.rpc.handle —— 0.1.5 回归,插件禁用

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

⚠ 0.1.5 回归:rpc.handle 对所有 out-of-tree 插件失效。 内部 register() 执行 owner.webServer.register(route),而 owner 是 connection 插件自己的 ctx,其 fiber 在 0.1.5 把 inject 从 ['webServer','credentials'] 缩成了 ['credentials'](/api 改由 ctx.inject(['webServer']) 延迟挂载)。cordis 4 严格属性访问随即抛 cannot get property "webServer" without inject。错误被 connection 的 catch 吞掉、host 无日志,浏览器表现为 405。

实测(cordis 4 最小复现,2026-09):给调用方插件加 webServer 也救不回来。 cordis 的 traceable 代理把 service.ctx 映射成「以 connection ctx 为 shadow 的访问方 ctx」 (createTraceable 的 prop === tracker.property → return ctx,配合 createShadow 回填 shadow), 而属性查找取 (ctx[shadow] ?? ctx).fiber,最终落在 connection 的 fiber 上 —— 与调用方 inject 无关:

inject=['connection']              -> rpc.handle THROW / fetch.register OK
inject=['connection','webServer']  -> rpc.handle THROW / fetch.register OK

所以唯一修法就是换成 connection.fetch.register(),不要试图靠补 webServer 依赖绕过。

插件不得使用 rpc.handle。 改用 connection.fetch.register()(见下方「四、0.1.5 推荐:connection.fetch.register()」)。rpc.intercept('/api', ...) 的拦截器席位已被 api-gateway 占用(每通道仅一个),插件不可抢。

4. 0.1.5 推荐:connection.fetch.register() —— 插件 RPC 的唯一正解

// packages/client/connection/src/rpc.ts
interface HostConnectionFetch {
  register(route: ConnectionFetchRoute): () => Promise<void>
}

interface ConnectionFetchRoute {
  readonly path: string                              // 精确路径,必须在 /api 下
  readonly methods: readonly ('GET' | 'HEAD' | 'POST')[]
  readonly requestBody: 'buffered' | 'streaming'     // buffered 受 JSON 体积上限保护
  readonly fetch: (request: Request) => Promise<Response>
}

匹配顺序:exact 路由表优先于 /api prefix 路由(见 HostConnectionService.createSharedFetchHandler),所以 /api/my-plugin/endpoint 能合法压过 /api 的通用分发。

插件注册模式:

const connection = ctx.get('connection') as { fetch?: HostConnectionFetch }
connection.fetch.register({
  path: '/api/my-plugin/my-endpoint',
  methods: ['POST'],
  requestBody: 'buffered',
  fetch: async (request: Request) => {
    // 1. 解析 wire 信封
    const { type, rpcId, payload } = await request.json()
    if (type !== 'client-request') {
      return new Response('malformed envelope', { status: 400 })
    }
    // 2. 业务逻辑
    const result = await handleRequest(payload)
    // 3. 返回 server-response 信封
    return Response.json({ type: 'server-response', rpcId, result })
  },
})

Wire 信封(0.1.5 契约):

| 方向 | 形状 | 说明 | |------|------|------| | Browser → Host | { type: 'client-request', rpcId: string, method: string, payload: unknown } | method 必须与路径匹配 | | Host → Browser | { type: 'server-response', rpcId: string, result: RpcResult } | RpcResult 必须含 ok + value 或 ok:false + error:{code, message, details} |

结果信封(0.1.5 严格要求):code 和 details 字段不可省略。浏览器 transport 校验失败会抛 invalid server-response:

type RpcResult =
  | { ok: true; value: unknown }
  | { ok: false; error: { code: string; message: string; details: object } }

宿主 inject 声明:插件宿主半需要 inject: ['systemPrompt', 'connection'](connection 提供 fetch 注册能力)。

浏览器调用方式:

// 浏览器半通过 connection.rpc.call 发起调用
ctx.connection.rpc.call('/api', 'my-plugin/my-endpoint', { args }, signal?)

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

  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.ts 的 rpcErrorSchema),没有 unauthorized / forbidden。业务层拒绝用 code: 'bad-request', details: { issues: [] };不要用 internal(部分客户端会触发重试)。HTTP 状态保持 200 —— 状态只表达载体层,业务错误一律走 200 + 错误分支。

四、踩坑

  • 切域名用 lastIndexOf('.') 并查映射表(见上)。
  • cordis 的 ctx.get(name) 返回代理对象:自有字段可枚举,原型上的属性不在 Object.keys 里。诊断时别只看 keys。
  • 测试里假 res 必须是 EventEmitter(补 on/off):invoke 会在 res 上挂 close 监听传取消信号,缺了就 500 ...is not a function。
  • 测试里假 req 别用 Object.assign(Readable.from(...), {...}):会覆盖 Readable 的 on/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。注入用户自由文本前要转义。
  • harness 0.1.5 的 connection.rpc.handle(channel, ...) 对插件全部失效(回归):内部 register() 执行 owner.webServer.register(route),而 owner 是 connection 插件自己的 ctx,其 fiber 在 0.1.5 把 inject 从 ['webServer','credentials'] 缩成了 ['credentials'](/api 改由 ctx.inject(['webServer']) 延迟挂载)。cordis 4 的严格属性访问随即抛 cannot get property "webServer" without inject。错误被 catch 吞掉、host 无日志,浏览器表现为 transport failure ... HTTP 405。正解:改用 connection.fetch.register()。
  • rpc.intercept('/api', ...) 的拦截器席位已被 api-gateway 占用(每通道仅一个),插件不可抢。想在 /api 上加功能端点,用 connection.fetch.register() 注册精确路由。
  • cordis 4 严格服务访问:ctx.webServer 这类属性访问要求当前 fiber 的 inject 声明该服务,否则抛 cannot get property "<service>" without inject;ctx.get(name) 不做此检查。(其他项目升级 0.1.5 后报「webServer 注入不存在」即此规则。)
  • rpc.handle 抛 webServer without inject 时,别去改插件的 inject:见第一节第 3 点的实测 —— owner 解析到 connection 自己的 fiber,补 webServer 依赖无效。直接换 connection.fetch.register()。
  • 自定义 exact Fetch 路由要自己解信封:createSharedFetchHandler 对 exact 路由直接调 route.fetch(request),不经过 rpcFetchHandler,所以 content-type / type==='client-request' / rpcId 回填都要自己处理;回包也必须是 { type:'server-response', rpcId, result },否则浏览器抛 invalid server-response。
  • connection 插件 inject 变更(0.1.5):从 ['webServer','credentials'] 缩减为 ['credentials']。webServer 改为通过 ctx.inject(['webServer'], ...) 延迟挂载。这意味着连接插件自己的 fiber 不再声明 webServer 依赖,rpc.handle 内部的 owner.webServer 访问会抛。插件不应假设 connection ctx 上可访问 webServer。

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

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

const ctx = { logger, get: n => ctx.services[n], services: { webServer, apiProxy }, effect: fn => effects.push(fn) }
  • 假 webServer:register 存进 Map 并暴露 request({ method, path, headers, body });tapIndex 收集变换。
  • 假 apiProxy:只实现被影子化的方法,返回 { rpcId, result }。
  • 清理时若 rmSync 报 EBUSY,说明有连接没释放 —— 一个免费的泄漏检测器。
  • 清理失败会掩盖测试本身的异常,把清理放在 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}
    • rpc.ts:HostConnectionFetch / ConnectionFetchRoute / ClientRequest / ServerResponse / ConnectionRpcResult 类型定义
    • rpc-host.ts:HostConnectionService 实现(fetch.register / rpc.handle / rpc.intercept)
  • packages/client/connection/src/api-request-trust.ts —— 信任栅栏(明确非鉴权)
  • packages/util/home-paths/src/index.ts —— DSH_HOME 解析