DeepSeek Harness 插件 rpc.handle 报 without inject:整树加载失败与修法
在 0.1.5-rc.1 上,任何调用 ctx.connection.rpc.handle() 的宿主插件都会让整棵插件树加载失败、Web UI 直接起不来,报错是:
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() 消费者:
dsh-automation
dsh-appearance-gallery
dsh-session-manager
dsh-turn-scrubber
把这个问题当成「某个插件自己的毛病」会修不完。
根因:owner 是谁,以及 inject 的两种形态不能混用
一句话:register() 拿的是 connection 插件自己的上下文,而这个插件的静态 inject 在本次发布里被去掉了 webServer。
1. 传递链
lib/index.js 里 rpc getter 把当前上下文交给 register():
// 539-546
get rpc() {
const owner = this.ctx
return {
handle: (channel, handler) => this.register(owner, channel, handler),
// ...
}
}
// 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. 声明在两个版本之间变了
// 0.1.5-rc.1
const inject = ["credentials"] // ← webServer 不在了
// 0.1.2 packages/client/connection/src/index.ts:67
export const inject = ['webServer', 'credentials']
现在这个插件改成在 apply() 里用动态 inject 获取 webServer(第 758 行),而动态 inject 不会扩展插件 fiber 自己的 inject map:
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):
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 没拦住
三条原因叠加:
- 树内没有任何插件使用
connection.rpc.handle()。树内消费者要么注册精确的 Fetch 路由(connection.fetch.register,从不碰owner.webServer),要么在自己声明了注入的插件里直接ctx.webServer.register()。 - 测试夹具把两个服务放进了同一上下文:
node-half.host.spec.ts注入了一个假的webServer,于是connection与webServer在测试上下文里共存,真实部署路径根本没有被走到。 - 这个树外插件看起来是唯一的
rpc.handle()消费者——直到有人去扫真实 profile 的依赖,才发现有 4 个。
5. 最小复现
- 安装
0.1.5-rc.1。 - 加任意一个宿主插件,在
apply()里调用ctx.connection.rpc.handle('/channel', handler, { authority: 'loopback' })。 - 启动
dsh web→ 插件树加载失败,Web UI 起不来。
修法一(插件作者,跨版本可用):自己拿到 webCtx 并当作 owner
核心思路:不要让 connection 插件替你解析 webServer——你自己注入,然后把那个已注入的子上下文作为 owner 传进去。
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
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 作为外层:
// 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 上:
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,两种写法的后果完全不同:
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())应当一并补进文档。
跨版本都安全的读法:
const events = typeof session.snapshotEvents === 'function'
? session.snapshotEvents()
: (session.events ?? [])
排查注意事项与来源
- 先确认报错的是谁的 fiber。
cannot get property "webServer" without inject出现在你自己的插件里,不代表问题在你的插件里。 - 别给调用方加
inject。这是最像正确答案的错误方向,实测完全无效,会浪费很长时间。 - 把 4 个消费者一次性找齐。load order 只报第一个;用
rpc.handle作为关键词扫一遍 profile 的直接依赖。 - 区分硬失败与 405。前者是加载期同步中止,后者是通道没挂上、调用时才暴露——同一个根因的两种表现。
- 确认调用的是
handle()。fetch.register与rpc.intercept不受影响,别一起改。 - 打补丁时别丢
owner.effect的外层语义。改成webCtx.effect(...)会让调用方卸载后通道继续存活。 ?? owner兜底别删。headless / tui 场景没有 webServer,保留原来的报错行为是刻意的。- 框架补丁不是万灵药。已有同版本上补丁无效的独立报告,动手前先按自己的调用形状验证。
- 升级时同步检查
Session.events。用typeof session.snapshotEvents === 'function'的跨版本读法,避免静默空转。 - 测试夹具里的假 webServer 会掩盖真实路径。如果你在写这类插件的测试,尽量覆盖「连接服务与 webServer 不在同一上下文」的形态。
来源
- #6227 — [Bug] dsh-client-connection@0.1.5-rc.1 fix(client-connection): register() resolves webServer without inject, breaking every rpc.handle() consumer
- #6337 — 独立复现:同包内
/api路由与rpc-host.handle()的写法对比,以及 CI 盲点 - #6270 — 通过 dsh-plugin-subscriptions 复现同一问题的实例
文中行号(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 命令行三种通道,已安装列表会标注来源与版本,系统日志页则按分类与级别记录安装、卸载与诊断轨迹,方便你在排查「装了哪个版本、改了什么」时快速对齐现场。

常见问题
因为这个错误是**同步抛出**的,而抛错点在插件树加载过程中,会直接中止整棵树的加载,不是只废掉你这个插件。更关键的是抛错的 fiber 不是你的——register() 用的是 this.ctx(connection 插件自己的上下文)去解析 owner.webServer,而 connection 插件的静态 inject 在 0.1.5-rc.1 里只剩 ["credentials"],所以它无权读 webServer。你无论在自己的插件里注入什么,都改变不了它的 fiber。
因为 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)。把这个当成「某个插件的问题」会修不完。
都不受影响,范围精确地就是 rpc.handle。fetch.register 走 registerFetchRoute,从不读取 owner.webServer;rpc.intercept 走 registerInterceptor,只动内部拦截器表。所以同一个 profile 里用这两条 API 的插件在 0.1.5-rc.1 上照常工作,只有 handle() 这一条路会炸。
确实存在这个未解释的差异。有一位报告人在真实 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
来源
- #6227 — [Bug] dsh-client-connection@0.1.5-rc.1 fix(client-connection): register() resolves webServer without inject, breaking every rpc.handle() consumer· deepseek-ai(GitHub Discussions)
- #6337 — 独立复现:同包内 /api 路由与 rpc-host.handle() 的写法对比,以及 CI 盲点· deepseek-ai(GitHub Discussions)
- #6270 — 通过 dsh-plugin-subscriptions 复现同一问题的实例· deepseek-ai(GitHub Discussions)