DeepSeek Harness 插件 rpc.handle 报 without inject:整树加载失败与修法

故障排查发布于 2026-10-03作者: DeepSeek Plugin 插件市场
DeepSeek HarnessDSHdsh-client-connectionrpc.handlecordisinject插件树破坏性变更
0.1.5-rc.1 上任何调用 ctx.connection.rpc.handle() 的插件都会让整棵插件树加载失败,报 webServer without inject:真正抛错的是 connection 插件自己的 fiber,往你的插件加注入没用。本文给两种修法。

在 0.1.5-rc.1 上,任何调用 ctx.connection.rpc.handle() 的宿主插件都会让整棵插件树加载失败、Web UI 直接起不来,报错是:

text
Error: dsh: plugin tree failed to load: failed to apply loader entry <plugin>: cannot get property "webServer" without inject
    at Fiber.<anonymous> (.../dsh-client-connection/lib/index.js:618:35)
    at Proxy.register (.../dsh-client-connection/lib/index.js:618:16)
    at Object.apply (.../cordis/lib/index.js:120:36)
    at Object.handle (.../dsh-client-connection/lib/index.js:543:39)
    at registerAutomationRpc (.../<plugin>/lib/index.js:134:29)

这句话最误导人的地方是:它看起来在说「你的插件没注入 webServer」——但往你自己的插件里加注入完全没用。 真因是 register() 用 this.ctx(connection 插件自己的上下文)去解析 owner.webServer,而 connection 的静态 inject 在 0.1.5-rc.1 里只剩 ["credentials"](0.1.2 时还是 ['webServer', 'credentials']),于是它自己无权读 webServer(#6227)。而且抛错是同步的、会中止整棵树加载,所以只有加载顺序里第一个消费者会被报出来——实测一个真实 profile 里这样的消费者有 4 个(#6227 回复)。下面按「先分清 owner 是谁 → 根因 → 两种修法 → 连带坑」展开。

先分诊:先搞清楚抛错的是谁的 fiber

关键结论:这不是「你没注入」,是「它没注入」。 分诊只做两件事:确认症状形态、确认调用的是哪条 API。

1. 两种症状,同一根因

形态表现说明
硬失败插件树加载失败、Web UI 起不来抛错发生在树加载过程中,同步中止整棵树
软失败插件加载正常,但端点返回 405通道没真正挂上 HTTP 服务器,调用时才暴露

两种都在同一个根因下:owner.webServer 解析不出来。硬失败因为发生在加载期,所以更显眼;软失败更隐蔽,容易误判成路由写错。

2. 只影响 rpc.handle 这一条 API

API走哪条路是否受影响
connection.rpc.handle()register() → 读 owner.webServer受影响
connection.fetch.register()registerFetchRoute()不受影响
connection.rpc.intercept()registerInterceptor(),只动内部拦截器表不受影响

所以同一个 profile 里,用后两条 API 的插件在 0.1.5-rc.1 上照常工作。排查时先确认调用的是不是 handle(),能省掉大量无用功。

3. 为什么「只报一个插件」

因为抛错是同步的,并且发生在插件树加载过程中——加载在第一个失败点整体中止,后面的同类消费者还没轮到就中断了。在真实 profile 的直接依赖里实测到 4 个 rpc.handle() 消费者:

text
dsh-automation
dsh-appearance-gallery
dsh-session-manager
dsh-turn-scrubber

把这个问题当成「某个插件自己的毛病」会修不完。

根因:owner 是谁,以及 inject 的两种形态不能混用

一句话:register() 拿的是 connection 插件自己的上下文,而这个插件的静态 inject 在本次发布里被去掉了 webServer。

1. 传递链

lib/index.js 里 rpc getter 把当前上下文交给 register():

js
// 539-546
get rpc() {
  const owner = this.ctx
  return {
    handle: (channel, handler) => this.register(owner, channel, handler),
    // ...
  }
}
js
// 602-619
register(owner, channel, handler) {
  assertChannel(channel)
  const fetchHandler = rpcFetchHandler(channel, handler)
  const route = { kind: 'prefix', path: channel, handler: async (req, res) => { /* ... */ } }
  return owner.effect(() => owner.webServer.register(route), `client-connection: ${channel} rpc channel`)
  //                    ^^^^^^^^^^^^^^^^ 这里解析的是 connection 插件自己的上下文
}

owner 就是 this.ctx——connection 插件自己的上下文,而不是调用方插件的。第 618 行要读 owner.webServer,就要求 connection fiber 在自己的 inject map 里声明 webServer。

2. 声明在两个版本之间变了

js
// 0.1.5-rc.1
const inject = ["credentials"]          // ← webServer 不在了
ts
// 0.1.2  packages/client/connection/src/index.ts:67
export const inject = ['webServer', 'credentials']

现在这个插件改成在 apply() 里用动态 inject 获取 webServer(第 758 行),而动态 inject 不会扩展插件 fiber 自己的 inject map:

js
ctx.inject(["webServer"], (webCtx) => {
  // ...
  webCtx.effect(() => webCtx.webServer.register(route), "client-connection: /api route")   // 781 行,正确写法
})

于是同一个包内出现了两种写法并存的情况:插件自己的 /api 路由走 webCtx(正确),而对外公开的 register() —— 也就是第三方通过 rpc.handle() 调用的那条 —— 仍在用 owner.webServer(错的)。这也是为什么树内测试抓不到(见下文)。

3. cordis 守卫为什么帮不上忙

抛错的守卫(reflect.ts / lib/index.js:680-694):

js
if (prop in fiber.inject) { error.message = `cannot get required service "${prop}" in inactive context`; throw error }
if (!fiber.runtime) throw error
if (fiber.parent[symbols.isolate][prop] !== key) throw error

它检查的是发起属性访问的那个 fiber。失败的 fiber 是 connection 插件的,所以:

在调用方插件的 inject 里加 webServer 没有任何效果。

这一点被两位独立报告人反复确认——而且报告人也特意写出来,因为「给调用方加注入」是这个报错最像正确答案的错误方向。

4. 为什么 CI 没拦住

三条原因叠加:

  1. 树内没有任何插件使用 connection.rpc.handle()。树内消费者要么注册精确的 Fetch 路由(connection.fetch.register,从不碰 owner.webServer),要么在自己声明了注入的插件里直接 ctx.webServer.register()。
  2. 测试夹具把两个服务放进了同一上下文:node-half.host.spec.ts 注入了一个假的 webServer,于是 connection 与 webServer 在测试上下文里共存,真实部署路径根本没有被走到。
  3. 这个树外插件看起来是唯一的 rpc.handle() 消费者——直到有人去扫真实 profile 的依赖,才发现有 4 个。

5. 最小复现

  1. 安装 0.1.5-rc.1。
  2. 加任意一个宿主插件,在 apply() 里调用 ctx.connection.rpc.handle('/channel', handler, { authority: 'loopback' })。
  3. 启动 dsh web → 插件树加载失败,Web UI 起不来。

修法一(插件作者,跨版本可用):自己拿到 webCtx 并当作 owner

核心思路:不要让 connection 插件替你解析 webServer——你自己注入,然后把那个已注入的子上下文作为 owner 传进去。

ts
let dispose: () => Promise<void> = async () => {}
ctx.inject(['webServer'], (webCtx) => {
  dispose = webCtx.connection.register(webCtx, '/my-channel', handler)
})
return async () => { await dispose() }

这段绕行在 0.1.2-rc.1 与 0.1.5-rc.1 上都验证可用。实测有插件按这个形状在 0.8.0 → 0.9.1 里修掉了自己的 405(#6270)。

这是插件作者当下最稳的选择:不依赖任何框架补丁、不受升级影响。

修法二(框架补丁):让 register() 用已注入的上下文解析

方案 A(提案里的方向):在 register() 内改走动态 inject

js
register(owner, channel, handler) {
  assertChannel(channel)
  const fetchHandler = rpcFetchHandler(channel, handler)
  const route = { kind: 'prefix', path: channel, handler: async (req, res) => { /* ... */ } }
  return owner.inject(["webServer"], (webCtx) =>
    webCtx.effect(() => webCtx.webServer.register(route), `client-connection: ${channel} rpc channel`))
}

另一条同等正当的路子是把这个插件需要 webServer 的事实还原到静态 inject 里——但要意识到这会改变 headless / SDK 场景的加载行为,很可能正是当初引入动态 inject 的原因。

方案 B(已验证的变体):attachWebContext + webCtx ?? owner

在 apply() 已有的 ctx.inject(['webServer'], webCtx => …) 里顺手把 webCtx 交给 service 保存;register() 用它解析,但保留 owner.effect 作为外层:

js
// apply()
ctx.inject(["webServer"], (webCtx) => {
  connection.webCtx = webCtx
  // ...原有 /api 路由注册不变
})

// register()
return owner.effect(
  () => (this.webCtx ?? owner).webServer.register(route),
  `client-connection: ${channel} rpc channel`
)

这里有一处必须保留的语义,别在改写时丢掉:

外层 effect 用谁通道生命周期跟随谁后果
owner.effect(...)(正确)调用方插件调用方卸载 → 通道随之销毁
webCtx.effect(...)(错误)connection 插件调用方卸载后通道仍挂在 HTTP 服务器上

而 ?? owner 这个兜底保留了当前在**没有 webServer 的部署(headless / tui)**上的行为——在那里抛错可能仍然是正确的信号。

方案 B 的实测结果(只改 dsh-client-connection)

检查项结果
插件树加载成功,无错误
插件条目141 启用 / 28 禁用 / 0 个加载错误
POST /api/pluginManager/list{"ok":true},返回完整条目列表
RPC 通道可达性/api/pluginManager/list 返回 200 结构化响应;未知端点 404

复现环境是装了全部四个 rpc.handle() 消费者的真实 profile,不是单元测试式确认。报告人说明:他是在 API 层验证的,没有走渲染后的 UI,所以「Web UI 能起来」这一点应视为上述结果的推论而非单独测量。

一个尚未解释的差异(务必知道)

另一位报告人在同一版本上打了同一份补丁(Windows 11、Node v22.23.2、0.1.5-rc.1),结果没有解决他的 405:同样是「注册时同步成功」、端点依旧 405,而诊断确认打过补丁的代码路径确实被加载并执行了。报告人自述无法解释这个差异,怀疑与自己的调用形状或时序/顺序细节有关,随后回退了框架补丁、改用插件侧修复。

结论:方案 B 不是无条件生效的。 如果你要走框架补丁这条路,先在自己的调用形状上验证,别假设它一定解决 405。

补丁的交付渠道

deepseek-ai/deepseek-harness 当前不接受外部 PR:CONTRIBUTING 明确写明,且仓库 has_issues: false、POST /repos/.../pulls 返回 404、导航里没有 Pull requests 入口。参考实现因此放在 fork 上:

text
branch: fix/client-connection-rpc-handle-webcontext
commit: cb9b6e2(基于 master)

改动集中在两个文件:packages/client/connection/src/index.ts(在已有的 ctx.inject(['webServer'], …) 里调用 connection.attachWebContext(webCtx))与 packages/client/connection/src/rpc-host.ts(新增 webCtx 字段与 attachWebContext(),并把 owner.webServer.register(route) 改成 (this.webCtx ?? owner).webServer.register(route))。

连带坑:Session.events 被静默换成了 snapshotEvents()

同一版本里还有一处未写进 release notes 的破坏性变更——它的危险版本是「什么都不报」。

Session.events 这个公开数组属性被 snapshotEvents() 方法取代。读旧属性会得到 undefined,两种写法的后果完全不同:

js
for (const event of session.events) { /* ... */ }         // TypeError: events is not iterable(会炸,好)
for (const event of session.events ?? []) { /* ... */ }   // 静默循环 0 次(不炸,坏)

第二种才是真正危险的:什么都不抛,功能只是悄悄停止产出。实测里一个自动摘要器因此不再往记忆库里写条目,直到另一个消费者在同一个属性上崩掉才被发现。

0.1.5 的 release notes 只记了「移除 ctx.agent,调用方需显式传递 Agent」这一条破坏性变更;session.events → snapshotEvents()(以及作为索引式读取替代的 eventAt() / ownEvents())应当一并补进文档。

跨版本都安全的读法:

js
const events = typeof session.snapshotEvents === 'function'
  ? session.snapshotEvents()
  : (session.events ?? [])

排查注意事项与来源

  1. 先确认报错的是谁的 fiber。cannot get property "webServer" without inject 出现在你自己的插件里,不代表问题在你的插件里。
  2. 别给调用方加 inject。这是最像正确答案的错误方向,实测完全无效,会浪费很长时间。
  3. 把 4 个消费者一次性找齐。load order 只报第一个;用 rpc.handle 作为关键词扫一遍 profile 的直接依赖。
  4. 区分硬失败与 405。前者是加载期同步中止,后者是通道没挂上、调用时才暴露——同一个根因的两种表现。
  5. 确认调用的是 handle()。fetch.register 与 rpc.intercept 不受影响,别一起改。
  6. 打补丁时别丢 owner.effect 的外层语义。改成 webCtx.effect(...) 会让调用方卸载后通道继续存活。
  7. ?? owner 兜底别删。headless / tui 场景没有 webServer,保留原来的报错行为是刻意的。
  8. 框架补丁不是万灵药。已有同版本上补丁无效的独立报告,动手前先按自己的调用形状验证。
  9. 升级时同步检查 Session.events。用 typeof session.snapshotEvents === 'function' 的跨版本读法,避免静默空转。
  10. 测试夹具里的假 webServer 会掩盖真实路径。如果你在写这类插件的测试,尽量覆盖「连接服务与 webServer 不在同一上下文」的形态。

来源

文中行号(dsh-client-connection/lib/index.js:539-546 / :602-619 / :618 / :758 / :781、cordis/lib/index.js:120 / :680-694、packages/client/connection/src/index.ts:67)、补丁前后检查表(141/28/0、{"ok":true}、200/404),以及 Session.events 的实测症状均来自上述讨论贴;「框架补丁在另一环境未解决 405」这一差异由报告人自述未能解释,本文如实保留。


写宿主插件时,最容易踩的坑往往不是业务逻辑,而是服务注入的归属与生命周期:谁有权限读哪个服务、资源该挂在谁的 effect 上、升级后哪些公开属性悄悄换了形态。DSH Plugin Hub 提供插件市场、已安装插件列表、自定义安装、设置与系统日志五个界面——自定义安装支持 NPM 包、GitHub 源码与 DSH 命令行三种通道,已安装列表会标注来源与版本,系统日志页则按分类与级别记录安装、卸载与诊断轨迹,方便你在排查「装了哪个版本、改了什么」时快速对齐现场。

DSH Plugin Hub 自定义安装:支持 NPM 包、GitHub 源码与 DSH 命令行三种安装通道

常见问题

我只是在自己的插件里调了一个 `rpc.handle()`,为什么整棵插件树都加载失败、Web UI 起不来?

因为这个错误是**同步抛出**的,而抛错点在插件树加载过程中,会直接中止整棵树的加载,不是只废掉你这个插件。更关键的是抛错的 fiber 不是你的——register() 用的是 this.ctx(connection 插件自己的上下文)去解析 owner.webServer,而 connection 插件的静态 inject 在 0.1.5-rc.1 里只剩 ["credentials"],所以它无权读 webServer。你无论在自己的插件里注入什么,都改变不了它的 fiber。

我在自己的插件里加了 `inject: ['webServer']`,为什么完全没用?

因为 cordis 的守卫检查的是**发起属性访问的那个 fiber**。实测反复确认过:往调用方插件的静态 inject 里加 webServer,得到的还是同一句 throw——这是最像「正确答案」的错误方向,很容易把人带偏很久。正确做法有两种:让调用方自己 ctx.inject(['webServer'], webCtx => …) 并把 webCtx 当作 owner 传进去;或者给框架打补丁,让 register() 改用已注入 webServer 的上下文来解析。

为什么只有一个插件报错,其他装了同样调用的插件看起来没事?

因为抛错是同步的、会中止整棵树的加载,所以**只有加载顺序里第一个** rpc.handle() 消费者会被报出来,后面的还没轮到就中断了。实测在一个真实 profile 的直接依赖里找到 **4 个**这样的消费者(dsh-automation、dsh-appearance-gallery、dsh-session-manager、dsh-turn-scrubber)。把这个当成「某个插件的问题」会修不完。

`connection.fetch.register` 和 `connection.rpc.intercept` 受影响吗?

都不受影响,范围精确地就是 rpc.handle。fetch.register 走 registerFetchRoute,从不读取 owner.webServer;rpc.intercept 走 registerInterceptor,只动内部拦截器表。所以同一个 profile 里用这两条 API 的插件在 0.1.5-rc.1 上照常工作,只有 handle() 这一条路会炸。

有人给框架打了补丁却仍然拿到 405,为什么?

确实存在这个未解释的差异。有一位报告人在真实 profile 上验证了 attachWebContext + webCtx ?? owner 补丁:插件树加载成功、0 个加载错误、POST /api/pluginManager/list 返回 {"ok":true}。但另一位报告人(Windows 11 / Node v22.23.2 / 0.1.5-rc.1)在同一版本上打同一补丁后,端点依然是 405——日志显示「注册成功」却仍 405,且诊断确认打过补丁的代码路径确实被执行了。差异原因未查明,可能与调用形状或时序有关。**如果你要走框架补丁,先在自己的调用形状上验证这一点。**

相关术语

cordis inject(服务注入声明)
cordis 里声明「这个插件依赖哪些服务」的机制。它有两种形态:静态 `export const inject = [...]` 会扩展插件 fiber 的 inject map,是访问服务属性的**权限**来源;`apply()` 里的动态 `ctx.inject([...], cb)` 只是创建一个已注入该服务的子上下文,**不会**扩展插件 fiber 自己的 inject map。混用这两种形态正是本 bug 的根源。— https://github.com/deepseek-ai/deepseek-harness/discussions/6227
cannot get property "webServer" without inject
cordis 在「访问一个本 fiber 无权读取的服务属性」时抛出的守卫错误。检查顺序是:目标属性在该 fiber 的 inject map 里 → 报「inactive context」;fiber 没有 runtime → 报错;父级 isolate 里的键不匹配 → 报错。因为它检查的是**发起访问的那个 fiber**,所以把注入加在调用方是最常见的误修方向。— https://github.com/deepseek-ai/deepseek-harness/discussions/6227
effect 归属(owner.effect 的语义)
`owner.effect(fn, label)` 把资源的生命周期绑定到 owner 所在的插件。对 `rpc.handle` 而言,**外层**用 `owner.effect(...)` 才能让通道随调用方插件一起销毁;换成 `webCtx.effect(...)` 则会让通道跟随 connection 插件存活,于是调用方卸载后它的通道仍然挂在 HTTP 服务器上。这是打补丁时最容易丢掉的一处语义。— https://github.com/deepseek-ai/deepseek-harness/discussions/6227
load-order 遮蔽(只报第一个消费者)
当抛错发生在插件树加载过程中且是同步的,加载会在第一个失败点整体中止,于是「谁先加载谁被报出来」,后面的同类问题完全不可见。这会让一个全局性回归看起来像「某个插件自己的毛病」,从而显著拉长排障时间。— https://github.com/deepseek-ai/deepseek-harness/discussions/6227

来源