Skip to content

fix(desktop): resolve plugin SDK namespaces lazily so disk plugins load in production builds - #107303

Merged
teknium1 merged 1 commit into
NousResearch:mainfrom
g3org3yo:fix/desktop-plugin-sdk-namespace
Sep 10, 2026
Merged

teknium1 merged 1 commit into
NousResearch:mainfrom
g3org3yo:fix/desktop-plugin-sdk-namespace

Conversation

@g3org3yo

@g3org3yo g3org3yo commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

What breaks

In a production (bundled) build of the desktop app, every plugin loaded from disk fails to load: $HERMES_HOME/desktop-plugins/<id>/plugin.js and the desktop/plugin.js half of a unified package alike. Capabilities → Plugins → Desktop plugins shows the row as failed with:

Cannot convert undefined or null to object

It is not plugin-specific — the throw happens before any plugin code runs, so there is no plugin-side workaround.

Root cause

apps/desktop/src/sdk/runtime.ts captured the SDK namespaces in a module-scope object literal:

const GLOBALS = {
  __HERMES_PLUGIN_SDK__: sdk,
  __HERMES_REACT__: React,
  __HERMES_REACT_JSX__: jsxRuntime,
  __HERMES_REACT_JSX_DEV__: jsxDevRuntime
} as const

That module sits in an import cycle:

sdk/index  ->  @/contrib/*  ->  contrib/runtime-loader  ->  sdk/runtime  ->  sdk/index
  • Dev (unbundled ESM): the cycle still yields a live namespace object, so Object.keys(sdk) works — which is why this is invisible in dev, in vitest, and in review.
  • Production bundle: the namespace becomes a hoisted var, and the bundler may order the two top-level statements either way. In the shipped 0.20.4 bundle it emits them like this (byte offsets inside one minified chunk):
statement offset
var Lg={__HERMES_PLUGIN_SDK__:Db,__HERMES_REACT__:J,__HERMES_REACT_JSX__:Y,…} 158,782
var Db=t({…}) — the plugin-SDK namespace itself 228,399

So Lg.__HERMES_PLUGIN_SDK__ is undefined when the literal is evaluated (var ⇒ no TDZ error), and the first Object.keys(GLOBALS[globalKey]) inside shimUrl() throws exactly TypeError: Cannot convert undefined or null to object.

Why every disk plugin dies

loadRuntimePlugin() runs installPluginSdk() and then unsupportedImports(source) → sdkImportMap() → shimUrl() for every source, before evaluating it, so the failure is content-independent.

Reproduction (production build only)

  1. Build apps/desktop for production and run the packaged app.
  2. Drop any plugin.js into ~/.hermes/desktop-plugins/<id>/.
  3. Start the app (or hit Reload desktop plugins).

desktop.log shows:

[renderer console:main] [plugins] runtime load failed (<id>) TypeError: Cannot convert
undefined or null to object (…/app.asar.unpacked/dist/assets/sdk-<hash>.js:5)

The fix

Resolve the namespaces at call time — installPluginSdk() and the shim builder only ever run once the app is up, so reading them there is always safe and statement ordering stops mattering. installPluginSdk() now reads a fresh pluginNamespaces(), and shimUrl() reads it per call.

Testing note (honest)

A vitest unit test cannot catch this: in the dev module graph the namespace object is live, so the old code passes too — the regression is bundler-ordering-only. Two options, reviewer's call: rely on the structural fix (module scope no longer captures the namespaces at all), or add a production-build smoke test that loads a fixture plugin through loadRuntimePlugin() and asserts it registers, which is the faithful reproducer.

Impact

Any user updating to a build with this ordering loses every on-disk desktop plugin. Same-shape siblings worth an audit while you are here: any other module-scope capture of a value from the sdk/index ↔ contrib cycle.

Fixes #107304

runtime.ts captured the SDK namespaces (the plugin SDK, React and the two jsx
runtimes) in a module-scope object literal, and that module sits in an import
cycle: sdk/index -> @/contrib/* -> contrib/runtime-loader -> sdk/runtime ->
sdk/index.

In an unbundled (dev) graph the namespace object is live, so the capture works.
In a production bundle the bundler emits the SDK namespace as a hoisted `var`
whose assignment lands AFTER the literal that reads it, so the captured value
is `undefined` (no TDZ error) and `Object.keys(GLOBALS[globalKey])` in
`shimUrl()` throws "Cannot convert undefined or null to object".

That throw happens inside `loadRuntimePlugin()` -- via `unsupportedImports()`
-> `sdkImportMap()` -> `shimUrl()`, which run for every source before it is
evaluated -- so it is content-independent: EVERY plugin loaded from
$HERMES_HOME/desktop-plugins/<id>/plugin.js (and the desktop/plugin.js half of
a unified package) fails to load, showing status "failed" in
Capabilities -> Plugins.

Resolve the namespaces at call time instead: `installPluginSdk()` and the shim
builder only ever run once the app is up, so reading them there is always safe
and statement ordering can no longer matter.

Repro (production build only): build apps/desktop for production, drop any
plugin.js into ~/.hermes/desktop-plugins/<id>/, start the app ->
[plugins] runtime load failed (<id>) TypeError: Cannot convert undefined or
null to object (.../assets/sdk-<hash>.js:5).

Note: a vitest unit test cannot catch this (dev module graph keeps the
namespace live); the faithful guard is a production-build smoke test that
loads a fixture plugin through loadRuntimePlugin().
@alt-glitch

Copy link
Copy Markdown
Contributor

This was generated by AI during triage.

Competing fixes for the same regression (#107288 / #107291 / #107304 / #107312 — every disk desktop plugin fails after #107212): #107301 breaks the sdk/index import cycle, #107303 resolves the SDK namespaces lazily, #107309 null-guards Object.keys. Cross-linking so a maintainer can pick one (or combine cycle-break + lazy read).

rikkarth added a commit to rikkarth/hermes-agent that referenced this pull request Sep 10, 2026
…ad in production builds

The module-scope GLOBALS capture sat in an import cycle
(sdk/index -> contrib/* -> contrib/runtime-loader -> sdk/runtime ->
sdk/index). Production bundlers may evaluate it before the captured
bindings are assigned, so Object.keys() threw 'Cannot convert undefined
or null to object' and blocked EVERY disk plugin before any plugin code
ran. Diagnosis credit: NousResearch#107303's byte-offset forensics.

Namespaces are now read at call time via pluginNamespaces(), when module
evaluation is long over. As a second layer, shimSource() emits a shim
that fails loudly at import time, naming the specifier and the missing
global, instead of killing map construction for unrelated plugins.
QAbsolut added a commit to QAbsolut/hermes-agent that referenced this pull request Sep 10, 2026
…ad in production builds

Root cause: a top-level  capturing the  namespace import
was emitted by the Rolldown bundler before the import's namespace variable was
populated in the output bundle. This left
as , causing every runtime (disk) plugin's shim blob to crash with
 in Object.keys().

Fix: replace the eager  with a  function
that resolves the bindings at call time, after all imports are initialized.
Both installPluginSdk() and shimUrl() now call this function lazily.

Upstream tracking: NousResearch/hermes-agent issues NousResearch#107312, NousResearch#107304, NousResearch#107336,
NousResearch#107288, NousResearch#107291, NousResearch#107352; PRs NousResearch#107303, NousResearch#107301, NousResearch#107309, NousResearch#107338, NousResearch#107405.
This commit is a local fix — REMOVE when upstream merges one of those PRs.
QAbsolut added a commit to QAbsolut/hermes-agent that referenced this pull request Sep 10, 2026
…ad in production builds

Root cause: a top-level  capturing the  namespace import
was emitted by the Rolldown bundler before the import's namespace variable was
populated in the output bundle. This left
as , causing every runtime (disk) plugin's shim blob to crash with
 in Object.keys().

Fix: replace the eager  with a  function
that resolves the bindings at call time, after all imports are initialized.
Both installPluginSdk() and shimUrl() now call this function lazily.

Upstream tracking: NousResearch/hermes-agent issues NousResearch#107312, NousResearch#107304, NousResearch#107336,
NousResearch#107288, NousResearch#107291, NousResearch#107352; PRs NousResearch#107303, NousResearch#107301, NousResearch#107309, NousResearch#107338, NousResearch#107405.
This commit is a local fix — REMOVE when upstream merges one of those PRs.
@Gdetrane

Copy link
Copy Markdown

Independent Linux verification of #107303 using the test companion in #107405 at 5b90ac4e56752c3e49aa2d1445ff95991eb15424, which preserves the original fix unchanged.

On Fedora 44 x86_64, Node 22.23.1 and Chromium headless shell 151.0.7922.34:

  • Bundled-cycle regression: 2/2 passed, no skips.
  • Production-graph smoke: passed, 4,087 modules, no uncaught browser errors. Plugin registration, SDK/React/JSX export identities, reload/disposal, shim caching, invalid-import rejection and React rendering passed.
  • Restoring the actual pre-fix runtime.ts from main 67764dc0863349a384c16425e73ee8571f3a94b7, without changing the tests, made both the positive bundler invariant and production smoke fail with Cannot convert undefined or null to object. Both commands exited 1. This was not the synthetic --baseline control. Restoring the fix made the fast suite green again.
  • Existing loader, plugins-settings and legacy-compatibility suites: 16/16 passed.

Follow-up quick check: rebased the two existing commits onto main ee35a4624fa22237a90426f5e21d8b4f2ce3a49b without conflicts; git range-diff reports both patches unchanged. The fast suite still passes 2/2, and 38/38 tests pass across the current loader, app-level plugin-root, package/settings UI and legacy-compatibility suites. The previous plugins-settings test was removed upstream, so current replacements were selected. This quick follow-up did not repeat the browser smoke; the production results above belong to the earlier snapshot.

For #107405's CI-placement question, I suggest a small package.json change: add check:test:plugin-sdk running the existing Node test file and include it in the local check chain. The existing workspace CI runner discovers check:* scripts automatically. I verified that discovery and ran the new script successfully. No new workflow or dependencies; the full browser smoke stays opt-in.

Scope: disposable checkout and production Vite graph in Chromium, not installed Linux Electron/package acceptance. No running Desktop or gateway was changed. Full-suite and hosted-CI success are not claimed; existing Vite warnings remained, and Playwright used its Ubuntu fallback browser build on Fedora.

Thanks for the fix and complementary tests. No competing implementation proposed. Verification and this draft were AI-assisted; recorded outputs and the rendered smoke screenshot were checked.

@Roader96

Copy link
Copy Markdown

Thanks — this fix matches exactly what I independently traced and hotfixed on the v0.21.1 packaged build (macOS arm64).

Same root cause, same shape of fix:

  • Shipped bundle: xg={__HERMES_PLUGIN_SDK__:Rb,…} (offset ~157,013) is emitted before Rb=t({…Badge:()=>Ds…}) (offset ~231,822), so the literal reads undefined; Object.keys(undefined) → every disk plugin fails with Cannot convert undefined or null to object.
  • My hotfix: replaced the literal with lazy getters (get __HERMES_PLUGIN_SDK__(){return Rb} …). After relaunch, Object.keys(globalThis.__HERMES_PLUGIN_SDK__) returns all ~100 named exports (Badge, Button, …) and plugins load. Your pluginNamespaces() is the source-level equivalent. ✔️

One extra data point for the release checklist: the follow-on symptom users may see after a partial guard ({} fallback) is SyntaxError: The requested module 'blob:…' does not provide an export named 'Badge' — same underlying cause, different surface. Worth a regression test around the shim having non-empty named exports (the guard names.length ? … : '' in shimUrl makes the empty case a silent no-export blob, which is exactly the trap).

Thanks for the quick fix! 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/desktop Electron desktop app (apps/desktop/*) comp/plugins Plugin system and bundled plugins P1 High — major feature broken, no workaround type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

desktop: every on-disk plugin fails to load in production builds ("Cannot convert undefined or null to object")

5 participants