DSH plugin: why rpc.handle's missing inject breaks the tree
On 0.1.5-rc.1, any host plugin calling ctx.connection.rpc.handle() makes the entire plugin tree fail to load and the Web UI never comes up, with the error:
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)
The most misleading thing about that sentence is that it looks like it is saying "your plugin did not inject webServer" — but adding an injection to your own plugin does nothing. The real cause is that register() uses this.ctx (the connection plugin's own context) to resolve owner.webServer, and the connection plugin's static inject in 0.1.5-rc.1 is down to ["credentials"] (in 0.1.2 it was still ['webServer', 'credentials']), so it has no right to read webServer itself (#6227). And because the throw is synchronous and aborts the whole tree's load, only the first consumer in load order gets reported — measured, a real profile has 4 such consumers (#6227 reply). Below we go "first figure out whose fiber it is → root cause → two fixes → a related trap".
Triage first: figure out whose fiber is throwing
Key conclusion: this is not "you did not inject", it is "it did not inject". Triage does just two things: confirm the symptom shape, and confirm which API was called.
1. Two symptoms, one root cause
| Shape | Behavior | Note |
|---|---|---|
| Hard failure | plugin tree fails to load, Web UI does not come up | the throw happens during tree loading and synchronously aborts the whole tree |
| Soft failure | the plugin loads fine, but the endpoint returns 405 | the channel was not really attached to the HTTP server, exposed only at call time |
Both are under the same root cause: owner.webServer cannot be resolved. Hard failure is more visible because it happens at load time; soft failure is sneakier and easily mistaken for a miswritten route.
2. Only the rpc.handle API is affected
| API | Which path | Affected |
|---|---|---|
connection.rpc.handle() | register() → reads owner.webServer | affected |
connection.fetch.register() | registerFetchRoute() | not affected |
connection.rpc.intercept() | registerInterceptor(), only touches the internal interceptor table | not affected |
So in the same profile, plugins using the latter two APIs work normally on 0.1.5-rc.1. During triage, confirm first whether the call is handle() — it saves a lot of wasted effort.
3. Why "only one plugin is reported"
Because the throw is synchronous and happens during plugin-tree loading — loading aborts wholesale at the first failure point, and later consumers of the same class never get their turn. Measured in a real profile's direct dependencies, there are 4 rpc.handle() consumers:
dsh-automation
dsh-appearance-gallery
dsh-session-manager
dsh-turn-scrubber
Treating this as "one particular plugin's own problem" will never be finished.
Root cause: who the owner is, and the two forms of inject that must not be mixed
In one sentence: register() grabs the connection plugin's own context, and that plugin's static inject dropped webServer in this release.
1. The forwarding chain
In lib/index.js, the rpc getter hands the current context to 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`)
// ^^^^^^^^^^^^^^^^ this resolves the connection plugin's own context
}
owner is this.ctx — the connection plugin's own context, not the caller plugin's. Line 618 needs to read owner.webServer, which requires the connection fiber to declare webServer in its own inject map.
2. The declaration changed between versions
// 0.1.5-rc.1
const inject = ["credentials"] // ← webServer is gone
// 0.1.2 packages/client/connection/src/index.ts:67
export const inject = ['webServer', 'credentials']
Now this plugin instead obtains webServer via dynamic inject inside apply() (line 758), and dynamic inject does not extend the plugin fiber's own inject map:
ctx.inject(["webServer"], (webCtx) => {
// ...
webCtx.effect(() => webCtx.webServer.register(route), "client-connection: /api route") // line 781, the correct form
})
So two forms coexist inside the same package: the plugin's own /api routes go through webCtx (correct), while the publicly exposed register() — the one third parties call via rpc.handle() — still uses owner.webServer (wrong). This is also why in-tree tests cannot catch it (see below).
3. Why the cordis guard does not help
The guard that throws (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
It checks the fiber that initiates the property access. The failing fiber is the connection plugin's, so:
Adding
webServerto the caller plugin'sinjecthas no effect whatsoever.
This was confirmed repeatedly by two independent reporters — and they called it out explicitly, because "add the injection to the caller" is the wrong direction that looks most like the right answer.
4. Why CI did not catch it
Three reasons stacked:
- No plugin in the tree uses
connection.rpc.handle(). In-tree consumers either register exact Fetch routes (connection.fetch.register, which never touchesowner.webServer) or callctx.webServer.register()directly inside a plugin that declares the injection itself. - The test fixture put both services in the same context:
node-half.host.spec.tsinjects a fakewebServer, soconnectionandwebServercoexist in the test context and the real deployment path was never exercised. - This out-of-tree plugin looked like the only
rpc.handle()consumer — until someone scanned a real profile's dependencies and found 4.
5. Minimal reproduction
- Install
0.1.5-rc.1. - Add any host plugin and inside
apply()callctx.connection.rpc.handle('/channel', handler, { authority: 'loopback' }). - Start
dsh web→ the plugin tree fails to load, the Web UI does not come up.
Fix one (plugin author, version-portable): grab webCtx yourself and pass it as the owner
Core idea: do not let the connection plugin resolve webServer for you — inject it yourself, then pass that injected child context in as the owner.
let dispose: () => Promise<void> = async () => {}
ctx.inject(['webServer'], (webCtx) => {
dispose = webCtx.connection.register(webCtx, '/my-channel', handler)
})
return async () => { await dispose() }
This workaround is verified working on both 0.1.2-rc.1 and 0.1.5-rc.1. Measured, a plugin fixed its own 405 in 0.8.0 → 0.9.1 using this shape (#6270).
This is the most stable choice for a plugin author today: it depends on no framework patch and is unaffected by upgrades.
Fix two (framework patch): make register() resolve with an already-injected context
Option A (the direction in the proposal): switch to dynamic inject inside register()
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`))
}
An equally legitimate route is to restore the fact that this plugin needs webServer into the static inject — but be aware this changes load behavior in headless / SDK scenarios, which is very likely why dynamic inject was introduced in the first place.
Option B (a verified variant): attachWebContext + webCtx ?? owner
In the apply()'s existing ctx.inject(['webServer'], webCtx => …), hand webCtx to the service for safekeeping; register() resolves with it, but keeps owner.effect as the outer layer:
// apply()
ctx.inject(["webServer"], (webCtx) => {
connection.webCtx = webCtx
// ...existing /api route registration unchanged
})
// register()
return owner.effect(
() => (this.webCtx ?? owner).webServer.register(route),
`client-connection: ${channel} rpc channel`
)
There is one semantic that must be preserved and must not be dropped when rewriting:
| Which effect for the outer layer | Whose lifetime the channel follows | Consequence |
|---|---|---|
owner.effect(...) (correct) | the caller plugin | caller unloads → channel destroyed with it |
webCtx.effect(...) (wrong) | the connection plugin | after the caller unloads, the channel is still attached to the HTTP server |
And the ?? owner fallback preserves the current behavior on deployments without a webServer (headless / tui) — where throwing may still be the correct signal.
Option B's measured result (changing only dsh-client-connection)
| Check | Result |
|---|---|
| plugin tree load | succeeds, no errors |
| plugin entries | 141 enabled / 28 disabled / 0 load errors |
POST /api/pluginManager/list | {"ok":true}, returns the full entry list |
| RPC channel reachability | /api/pluginManager/list returns a 200 structured response; unknown endpoint 404 |
The reproduction environment is a real profile with all four rpc.handle() consumers installed, not a unit-test-style confirmation. The reporter notes he verified at the API layer and did not go through the rendered UI, so "the Web UI comes up" should be treated as a corollary of the above rather than a separate measurement.
One unexplained difference (be sure to know it)
Another reporter applied the same patch on the same version (Windows 11, Node v22.23.2, 0.1.5-rc.1) and it did not fix his 405: registration synced successfully the same way, the endpoint was still 405, and diagnosis confirmed the patched code path really was loaded and executed. The reporter says he cannot explain the difference and suspects it relates to his call shape or timing/ordering details; he then reverted the framework patch and switched to a plugin-side fix.
Conclusion: Option B does not work unconditionally. If you go the framework-patch route, verify it on your own call shape first; do not assume it will fix the 405.
The patch's delivery channel
deepseek-ai/deepseek-harness does not currently accept external PRs: CONTRIBUTING states this explicitly, and the repository has has_issues: false, POST /repos/.../pulls returns 404, and there is no Pull requests entry in the navigation. The reference implementation therefore lives on a fork:
branch: fix/client-connection-rpc-handle-webcontext
commit: cb9b6e2 (based on master)
The change is concentrated in two files: packages/client/connection/src/index.ts (calling connection.attachWebContext(webCtx) inside the existing ctx.inject(['webServer'], …)) and packages/client/connection/src/rpc-host.ts (adding a webCtx field and attachWebContext(), and changing owner.webServer.register(route) to (this.webCtx ?? owner).webServer.register(route)).
A related trap: Session.events was silently replaced by snapshotEvents()
The same version also carries a breaking change not written into the release notes — its dangerous variant is "reports nothing at all".
The public array property Session.events was replaced by the snapshotEvents() method. Reading the old property yields undefined, and the two forms of code have completely different consequences:
for (const event of session.events) { /* ... */ } // TypeError: events is not iterable (blows up, good)
for (const event of session.events ?? []) { /* ... */ } // silently loops 0 times (no blow-up, bad)
The second is the truly dangerous one: it throws nothing, and a feature just quietly stops producing. Measured, an automatic summarizer therefore stopped writing entries into its memory store, and it was only discovered when another consumer crashed on the same property.
0.1.5's release notes recorded only one breaking change — "removed ctx.agent, callers must pass the Agent explicitly"; session.events → snapshotEvents() (and eventAt() / ownEvents() as index-style read replacements) should have been added to the docs too.
A cross-version-safe read:
const events = typeof session.snapshotEvents === 'function'
? session.snapshotEvents()
: (session.events ?? [])
Troubleshooting notes
- First confirm whose fiber is throwing.
cannot get property "webServer" without injectappearing in your own plugin does not mean the problem is in your plugin. - Do not add
injectto the caller. This is the wrong direction that looks most like the right answer; measured, it does nothing and wastes a lot of time. - Find all 4 consumers at once. Load order reports only the first; grep the profile's direct dependencies for
rpc.handleas a keyword. - Distinguish hard failure from 405. The former is a synchronous abort at load time, the latter is a channel that never attached, exposed only at call time — two manifestations of one root cause.
- Confirm the call is
handle().fetch.registerandrpc.interceptare unaffected; do not change them together. - Do not drop the outer
owner.effectsemantics when patching. Switching towebCtx.effect(...)keeps the channel alive after the caller unloads. - Do not delete the
?? ownerfallback. headless / tui scenarios have no webServer, and preserving the original throwing behavior is intentional. - The framework patch is not a panacea. There is already an independent report of the patch not working on the same version; verify against your own call shape before acting.
- Check
Session.eventsin sync when upgrading. Use the cross-version read withtypeof session.snapshotEvents === 'function'to avoid a silent no-op. - A fake webServer in the test fixture masks the real path. If you write tests for this class of plugin, try to cover the shape where "the connection service and webServer are not in the same context".
When writing host plugins, the easiest thing to trip over is usually not the business logic but the ownership and lifetime of service injection: who has the right to read which service, whose effect a resource should hang on, and which public properties quietly changed shape after an upgrade. DSH Plugin Hub provides five screens: plugin market, installed list, custom install, settings, and system logs — custom install supports three channels (npm package, GitHub source, and DSH command line), the installed list labels source and version, and the system log page records install, uninstall, and diagnostic trails by category and level, so you can quickly align the scene when troubleshooting "which version was installed, and what changed".

Source: Discussion #6227, Discussion #6337, Discussion #6270.
FAQ
Because the error is thrown **synchronously** and the throw site is during plugin-tree loading, so it aborts the entire tree's load, not just your plugin. More importantly, the fiber that throws is not yours — register() uses this.ctx (the connection plugin's own context) to resolve owner.webServer, and the connection plugin's static inject in 0.1.5-rc.1 is down to ["credentials"], so it has no right to read webServer. Whatever you inject in your own plugin cannot change its fiber.
Because cordis's guard checks **the fiber that initiates the property access**. Verified repeatedly by measurement: adding webServer to the caller plugin's static inject yields the exact same throw — this is the wrong direction that looks most like the right answer, and it easily wastes a long time. The right approaches are two: have the caller do its own ctx.inject(['webServer'], webCtx => …) and pass webCtx in as the owner; or patch the framework so register() resolves using a context that already has webServer injected.
Because the throw is synchronous and aborts the entire tree's load, so **only the first rpc.handle() consumer in load order** gets reported; the rest never get their turn. Measured, in a real profile's direct dependencies there are **4** such consumers (dsh-automation, dsh-appearance-gallery, dsh-session-manager, dsh-turn-scrubber). Treating this as "one particular plugin's problem" will never be finished.
No, the scope is precisely rpc.handle. fetch.register goes through registerFetchRoute and never reads owner.webServer; rpc.intercept goes through registerInterceptor and only touches the internal interceptor table. So in the same profile, plugins using those two APIs work fine on 0.1.5-rc.1; only the handle() path blows up.
That unexplained difference really exists. One reporter verified the attachWebContext + webCtx ?? owner patch on a real profile: the plugin tree loads, 0 load errors, and POST /api/pluginManager/list returns {"ok":true}. But another reporter (Windows 11 / Node v22.23.2 / 0.1.5-rc.1) applied the same patch on the same version and the endpoint was still 405 — the log showed "registered successfully" yet still 405, and diagnosis confirmed the patched code path really was executed. The cause of the difference is unknown and may relate to the call shape or timing. **If you go the framework-patch route, verify it on your own call shape first.**
Related Terms
- cordis inject (service injection declaration)
- cordis's mechanism for declaring "which services this plugin depends on". It has two forms: a static `export const inject = [...]` extends the plugin fiber's inject map and is the **permission** source for accessing service properties; a dynamic `ctx.inject([...], cb)` inside `apply()` merely creates a child context with that service injected and does **not** extend the plugin fiber's own inject map. Mixing these two forms is the root of this bug.— https://github.com/deepseek-ai/deepseek-harness/discussions/6227
- cannot get property "webServer" without inject
- The guard error cordis throws when "accessing a service property this fiber has no right to read". The check order is: is the target property in this fiber's inject map → error "inactive context"; does the fiber have a runtime → error; does the key in the parent isolate not match → error. Because it checks **the fiber initiating the access**, adding the injection on the caller is the most common mis-fix direction.— https://github.com/deepseek-ai/deepseek-harness/discussions/6227
- effect ownership (the semantics of owner.effect)
- `owner.effect(fn, label)` binds a resource's lifetime to the plugin the owner belongs to. For `rpc.handle`, the **outer** layer must use `owner.effect(...)` so the channel is destroyed together with the caller plugin; switching to `webCtx.effect(...)` makes the channel live as long as the connection plugin, so after the caller unloads its channel is still attached to the HTTP server. This is the semantic most easily dropped when patching.— https://github.com/deepseek-ai/deepseek-harness/discussions/6227
- load-order masking (only the first consumer is reported)
- When the throw happens during plugin-tree loading and is synchronous, loading aborts wholesale at the first failure point, so "whoever loads first gets reported" and later instances of the same class are invisible. This makes a global regression look like "one plugin's own problem" and significantly lengthens troubleshooting.— https://github.com/deepseek-ai/deepseek-harness/discussions/6227
Sources
- #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 — independent reproduction: comparing in-package /api routes with rpc-host.handle(), and the CI blind spot· deepseek-ai (GitHub Discussions)
- #6270 — an instance reproducing the same issue via dsh-plugin-subscriptions· deepseek-ai (GitHub Discussions)