Skip to content

Legacy plugin loader pushes non-Hooks return values, corrupting plugin loading and crashing startup #42451

Closed as not planned

Description

@clayleopardlabs

Summary

opencode 1.16.2's legacy plugin loader (getLegacyPlugins in packages/opencode/src/plugin/index.ts) invokes EVERY exported function of a plugin module as a plugin and pushes its return value into the hooks array WITHOUT validating it is a Hooks object. A plugin that exports helper functions that return undefined/false (in addition to the real plugin factory) pollutes the hooks array with non-Hooks entries. This corrupts plugin loading and can crash startup without a visible load error.

The same root cause explains two crash signatures observed at startup:

  • config-hook loop: (hook as any).config?.(cfg) throws Cannot read properties of undefined (reading 'config') (looped under Effect.ignore, so non-fatal but logged).
  • provider state initializer (packages/opencode/src/provider/provider.ts, instanceState.make does for (const hook of plugins) { const p = hook.provider; ... }): undefined.provider throws Cannot read properties of undefined (reading 'provider'). This loop is NOT wrapped in Effect.ignore, so it aborts provider state init and crashes Provider.list.

Context

Custom plugins written as { id: <plugin>, ...hooks } legacy-style modules, or plugin modules (built with the @opencode-ai/plugin SDK) that chose named exports: the plugin factory plus adjacent helper functions. Example: a plugin module exporting isLockdownError, handleRateLimit, timerTick, and <Plugin> as four named function exports. The legacy loader treats all four as plugin factories.

Root cause detail

  1. readV1Plugin(mod, "detect") returns undefined when mod.default is absent or is a function (only a record with id/server/tui keys routes to the v1 path). So any module without a record default falls through to the legacy path.
  2. getLegacyPlugins(mod) iterates Object.values(mod) and calls getServerPlugin(entry) (which only checks typeof value === "function"). Every function export is treated as a plugin.
  3. applyPlugin does hooks.push(await entry(input, load.options)) with no check that the returned value is a Hooks object. Helper functions that return undefined or false get pushed into the shared hooks array.
  4. Later consumers index the array as if every entry were a Hooks object. The fatal one is instanceState.make: const p = hook.provider on an undefined entry throws.

Expected behavior

A plugin that returns a non-Hooks value from its module export should fail that plugin load gracefully and log a clear error, rather than polluting the hooks array and crashing a later consumer. At minimum, getLegacyPlugins / applyPlugin should filter out entries whose value is not a Hooks object.

Impact

  • Crash feat: compact and other improvements #1 (load list: magic-context, instant-file-search, mullvad, server-start-guard, bmc-panel, speak, gk-hooks): 2x j.config + t.provider crash.
  • Crash Roadmap & Existing Issues #2 (same minus bmc-panel and server-start-guard): identical 2x j.config + t.provider crash.
  • Clean boot (crashing plugin removed): zero errors.

The single discriminating variable between the crashing boots and the clean boot was the plugin with the helper exports returning undefined.

Environment

  • opencode 1.16.2 (WinGet install)
  • @opencode-ai/plugin SDK 1.17.8
  • Loader ref: tag v1.16.2 = commit 76c631d

Activity

  1. github-actions commented on Aug 14, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

  2. MilkyWay008 commented on Aug 14, 2026

    @MilkyWay008

    Had this exact crash after a plugin update a few weeks back. Easiest way to get back up: rename the plugin folder at %USERPROFILE%\.config\opencode\plugin\ to something else, boot opens clean, then add plugins back one at a time to find the offender. Your root cause reads right to me, any named function export gets treated as a plugin factory, so keeping helper exports in a separate module fixes it at the source. Oh, btw, in case it might help that, I built a portable Hermes OTG repo, check it, imo it may be able to help you diagnose and figure out the fix in no time. https://github.com/MilkyWay008/Hermes-OTG

  3. Ev3lynx727 commented on Aug 17, 2026

    @Ev3lynx727

    Same root cause, third symptom — confirmed on v1.18.9 (Linux x64).

    Symptom: failed to load plugin @dietrichgebert/ponytail error="path must be a string or a file descriptor" at startup.

    Trace: the ponytail module exports the plugin as default (a plain function, so readV1Plugin(..., "detect") misses it) plus a named helper parseCommandFile(filePath). getLegacyPlugins calls every function export with the PluginInput object → parseCommandFile(input) → fs.readFileSync(input) → TypeError: path must be a string or a file descriptor. The entire plugin silently fails to load; the error gives zero hint that a non-plugin export is the culprit.

    Confirming evidence for this issue: same "invoke every export" behavior, surfacing as a raw fs TypeError instead of the undefined.config/undefined.provider crashes. Repro is trivial — any legacy plugin module that also exports a named helper function that touches fs.

    Possible fix direction: in getLegacyPlugins, only treat the default export (or exports matching an explicit plugin contract) as plugins and skip the rest, plus wrap invocations so the error names the offending export. A plugin author who exports utilities alongside the plugin is a legitimate pattern and shouldn't be a footgun.

  4. Ghilteras commented on Aug 21, 2026

    @Ghilteras

    Confirming on 1.18.19, with the third crash site

    Hit this today on 1.18.19 (latest release) and it took out an entire agent fleet. Adding our
    data because the root cause in this issue is exactly right and the code is unchanged on dev.

    Trigger. One plugin file exported its Plugin factory plus four plain helper functions — the
    helpers were exported only so a unit test could import them:

    export const WorktreeGuard: Plugin = async ({ client }) => ({ /* real hooks */ })
    
    // exported purely for the unit test
    export function isWorktreePath(p: string) { return p.includes("/worktrees/") }
    export function worktreeName(p: string) { return p.split("/").pop() }
    export function assertClean() { /* returns undefined */ }
    export function formatWarning(n: string) { return `worktree ${n} is dirty` }

    getLegacyPlugins calls all five with (PluginInput, options), so hooks ends up as
    [object, boolean, string, undefined, string].

    Symptoms. All three guarded consumers logged, and then message creation died:

    level=ERROR message="plugin config hook failed" error="undefined is not an object (evaluating 'N.config')"
    level=ERROR message="Event listener failed" eventType=session.created ... (evaluating 'V.event')
    level=ERROR ... "TypeError: undefined is not an object (evaluating 'H[W]')"
        at Plugin.trigger -> SessionPrompt.createUserMessage -> SessionPrompt.prompt
    

    That last one is the fourth unguarded site, on top of the provider.ts one already in this
    issue: Plugin.trigger (packages/opencode/src/plugin/index.ts:291-294) does
    for (const hook of s.hooks) { const fn = hook[name] ... } with no null check, so no session
    could create a user message at all
    . Not degraded — zero messages.

    Why this is worse than it looks. The visible failure was at the model call, so we spent the
    outage debugging our model provider. The plugin errors are Effect.ignored and only reachable by
    grepping the log; nothing connects them to "sessions cannot send messages". Deleting the four
    helper exports fixed it completely, with no other change.

    Reproduces deterministically with the loader semantics extracted from dev:

    BEFORE: hooks = [object, boolean, string, undefined, string]
      config:   Cannot read properties of undefined (reading 'config')
      event:    Cannot read properties of undefined (reading 'event')
      trigger:  Cannot read properties of undefined (reading 'chat.message')
      provider: Cannot read properties of undefined (reading 'provider')
    

    Also worth noting for whoever picks this up: PR #35489 fixed the non-function half of this and was
    auto-closed by the stale-PR bot on 2026-08-07, not rejected. I have a patch that covers both this
    issue and #41234 and will open it as a PR.


    Opened #43785 with a fix for this (and #41234): each export is validated independently — non-function exports, factories that throw, and factories returning a non-object are skipped with a warning naming the export and the reason, while a module with no usable plugin export still throws so genuinely broken plugins stay loud.

  5. sitex commented on Aug 29, 2026

    @sitex

    Confirmed on OpenCode 1.18.25, Linux x64.

    A local plugin exported its default factory plus one named helper used by tests. Normal opencode startup logged:

    plugin config hook failed: undefined is not an object (evaluating N.config)
    Provider.list: undefined is not an object (evaluating n.provider)
    plugin dispose hook failed: undefined is not an object (evaluating N.dispose)
    

    The TUI surfaced only Unexpected server error. opencode --pure opened normally with the same provider/model config.

    Toggle proof: with the named helper export present, opencode models cliproxyapi exited 1 on n.provider; making only that helper module-private listed all 7 configured models, and the exact opencode command rendered the TUI. Restoring the export reproduced the crash; removing it restored startup.

    The workaround is to expose only the default plugin factory. We also changed the test to obtain the helper through the returned tool.execute.before hook and assert Object.keys(pluginModule) equals ["default"]. This remains reproducible because applyPlugin() on current dev still pushes await server(...) without validating the returned Hooks object.

  6. mike-ildan commented on Aug 30, 2026

    @mike-ildan

    Adding a fourth crash site and a published-package impact. Not re-confirming the version, since @sitex already covered 1.18.25 above, and not restating the root cause, which the issue body already gets right.

    The Plugin.trigger site, which fires mid-session rather than at startup

    The consumers listed so far are the config loop, the dispose loop, and Provider.list / provider state init. There is one more, and it is the one that reaches the user as a broken prompt rather than a broken boot.

    packages/opencode/src/plugin/index.ts#L292, inside Plugin.trigger:

    for (const hook of s.hooks) {
      const fn = hook[name] as any     // hook can be null: same unvalidated push from L123
      if (!fn) continue

    if (!fn) continue guards the hook's property but never the hook itself, so a null entry throws on indexing. Captured on 1.18.25:

    level=ERROR message="plugin config hook failed" error="null is not an object (evaluating 'N.config')"
    level=ERROR message=failed error="TypeError: null is not an object (evaluating 'z[W]')"
        at Plugin.trigger
        at SessionPrompt.createUserMessage
        at SessionPrompt.prompt
        at SessionHttpApi.prompt
    Error: { "name": "UnknownError",
             "data": { "message": "Unexpected server error. Check server logs for details." } }
    

    Startup completes normally here. The session opens, the model is listed, and the failure lands on the first prompt, which sends people looking at their provider or their network rather than at a plugin. Worth noting because it means the blast radius is not limited to boot.

    Also a small addition to the reported shapes: every example in the thread so far has a helper returning undefined or false. This one returns null, from ordinary lookup helpers (a tool-name translation that finds no match, a parse that does not match). null is the common case in real code, and no argument guard can prevent it, which is worth weighing against any fix that only hardens the call.

    A published plugin is fully broken by this

    opencode-claude-bridge@1.10.12, which is the current latest on npm, does not load at all on 1.18.25. It exports six ordinary helpers alongside its default, two of which throw when called with the plugin input, so getLegacyPlugins aborts the whole file:

    level=ERROR message="failed to load plugin"
      path=file:///.../opencode-claude-bridge/dist/index.js
      error="modelId.match is not a function..."
    

    Filed there as dotCipher/opencode-claude-bridge#27 with a fix. Mentioning it here only as evidence that this reaches past locally-authored plugins into published ones, where users cannot see the cause and have no workaround short of editing node_modules.

    Minimal repro

    // p.ts
    export function returnsNull() { return null }
    export default async () => ({})
    { "plugin": ["file:///abs/path/p.ts"] }

    opencode run "hi" → three plugin config hook failed, then the fatal z[W]. Startup is clean; only the prompt fails.

    One diagnostic note for anyone bisecting: opencode debug config does not load plugins, so it reports success for a plugin that cannot load. opencode debug info does.

    On the fix

    The issue body's suggestion (filter non-Hooks entries at applyPlugin) would close all four sites at once, and is the right layer. If a narrower change is preferred, L292 needs its own guard regardless, since if (!fn) does not cover a null hook.

    Analysis was AI-assisted. Every log line and stack above is captured output from an actual run, not reconstructed; the source line references are against the current default branch.

  7. thdxr commented on Oct 11, 2026

    @thdxr
    Member

    Thanks for the report! OpenCode 2.0 is now out and development is focused on V2, so we're closing issues filed against OpenCode 1. Please upgrade (opencode upgrade --major, details: https://anoma.ly/notes/opencode-2-0), and if this is still relevant on 2.0, reopen this issue or open a new one with your 2.0 version.

    — from 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions