DSH plugin: why rpc.handle's missing inject breaks the tree

TroubleshootingPublished 2026-10-03Author: DeepSeek Plugin Market
DeepSeek HarnessDSHdsh-client-connectionrpc.handlecordisinjectplugin treebreaking change
On 0.1.5-rc.1 any plugin calling ctx.connection.rpc.handle() makes the whole plugin tree fail to load with webServer without inject, so the Web UI never starts.

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:

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)

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

ShapeBehaviorNote
Hard failureplugin tree fails to load, Web UI does not come upthe throw happens during tree loading and synchronously aborts the whole tree
Soft failurethe plugin loads fine, but the endpoint returns 405the 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

APIWhich pathAffected
connection.rpc.handle()register() → reads owner.webServeraffected
connection.fetch.register()registerFetchRoute()not affected
connection.rpc.intercept()registerInterceptor(), only touches the internal interceptor tablenot 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:

text
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():

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`)
  //                    ^^^^^^^^^^^^^^^^ 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

js
// 0.1.5-rc.1
const inject = ["credentials"]          // ← webServer is gone
ts
// 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:

js
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):

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

It checks the fiber that initiates the property access. The failing fiber is the connection plugin's, so:

Adding webServer to the caller plugin's inject has 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:

  1. No plugin in the tree uses connection.rpc.handle(). In-tree consumers either register exact Fetch routes (connection.fetch.register, which never touches owner.webServer) or call ctx.webServer.register() directly inside a plugin that declares the injection itself.
  2. The test fixture put both services in the same context: node-half.host.spec.ts injects a fake webServer, so connection and webServer coexist in the test context and the real deployment path was never exercised.
  3. 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

  1. Install 0.1.5-rc.1.
  2. Add any host plugin and inside apply() call ctx.connection.rpc.handle('/channel', handler, { authority: 'loopback' }).
  3. 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.

ts
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()

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

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:

js
// 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 layerWhose lifetime the channel followsConsequence
owner.effect(...) (correct)the caller plugincaller unloads → channel destroyed with it
webCtx.effect(...) (wrong)the connection pluginafter 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)

CheckResult
plugin tree loadsucceeds, no errors
plugin entries141 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:

text
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)).

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:

js
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:

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

Troubleshooting notes

  1. First confirm whose fiber is throwing. cannot get property "webServer" without inject appearing in your own plugin does not mean the problem is in your plugin.
  2. Do not add inject to the caller. This is the wrong direction that looks most like the right answer; measured, it does nothing and wastes a lot of time.
  3. Find all 4 consumers at once. Load order reports only the first; grep the profile's direct dependencies for rpc.handle as a keyword.
  4. 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.
  5. Confirm the call is handle(). fetch.register and rpc.intercept are unaffected; do not change them together.
  6. Do not drop the outer owner.effect semantics when patching. Switching to webCtx.effect(...) keeps the channel alive after the caller unloads.
  7. Do not delete the ?? owner fallback. headless / tui scenarios have no webServer, and preserving the original throwing behavior is intentional.
  8. 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.
  9. Check Session.events in sync when upgrading. Use the cross-version read with typeof session.snapshotEvents === 'function' to avoid a silent no-op.
  10. 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".

DSH Plugin Hub · Custom install: three install channels — npm package, GitHub source, and DSH command line

Source: Discussion #6227, Discussion #6337, Discussion #6270.

FAQ

I only called one `rpc.handle()` in my own plugin — why does the whole plugin tree fail to load and the Web UI not come up?

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.

I added `inject: ['webServer']` to my own plugin — why does it do nothing?

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.

Why does only one plugin report an error while other plugins with the same call look fine?

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.

Are `connection.fetch.register` and `connection.rpc.intercept` affected?

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.

Someone patched the framework yet still gets a 405 — why?

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