Files
elizaos--eliza/packages/docs/plugin-resolution-and-node-path.md
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

146 lines
11 KiB
Markdown

---
title: "Plugin Resolution and NODE_PATH"
sidebarTitle: "Plugin Resolution"
description: "Why dynamic plugin imports fail without NODE_PATH and how Eliza fixes it across CLI, dev server, and Electrobun."
---
# Plugin resolution: why NODE_PATH is needed
This doc explains **why** dynamic plugin imports fail without `NODE_PATH` and **how** we fix it across CLI, dev server, and Electrobun.
> **Note:** The source files referenced in this document live in the elizaOS submodule (`eliza/`). Run `git submodule update --init --recursive` to populate the submodule so you can inspect them locally. All paths like `src/runtime/eliza.ts` refer to `eliza/packages/app-core/src/runtime/eliza.ts` unless otherwise noted.
## The problem
The runtime (`src/runtime/eliza.ts`) loads plugins via dynamic import:
```ts
import("@elizaos/plugin-sql")
```
Node resolves this by walking up from the **importing file's directory**. When eliza runs from different locations, resolution can fail:
| Entry point | Importing file location | Walks up from | Reaches root `node_modules`? |
|---|---|---|---|
| `bun run dev` | `src/runtime/eliza.ts` | `src/runtime/` | Usually yes (2 levels) |
| `bun run dev` (CLI) | `dist/runtime/eliza.js` | `dist/runtime/` | Usually yes (2 levels) |
| Electrobun dev | `eliza-dist/eliza.js` | `packages/app-core/platforms/electrobun/eliza-dist/` | **No** — walks into `apps/` |
| Electrobun packaged | `app.asar.unpacked/eliza-dist/eliza.js` | Inside the `.app` bundle | **No** — different filesystem |
In the Electrobun cases (and sometimes the built dist case depending on bundler behavior), the walk never reaches the repo root where `@elizaos/plugin-*` packages are installed. The import fails with "Cannot find module".
## The fix: NODE_PATH
`NODE_PATH` is a Node.js environment variable that adds extra directories to module resolution. We set it in **three places** so every entry path resolves plugins:
### 1. `src/runtime/eliza.ts` (module-level)
```ts
const _repoRoot = path.resolve(_elizaDir, "..", "..");
const _rootModules = path.join(_repoRoot, "node_modules");
if (existsSync(_rootModules)) {
process.env.NODE_PATH = ...;
Module._initPaths();
}
```
**Why here:** Covers `bun run dev` (dev-server.ts imports eliza directly) and any other in-process import of eliza. The `existsSync` guard skips this branch in packaged apps where the repo root doesn't exist.
**Note on `Module._initPaths()`:** It is a private Node.js API but widely used for exactly this purpose (runtime NODE_PATH mutation). Node caches resolution paths at startup; after we set `process.env.NODE_PATH` we must call it so the next `import()` sees the new paths.
### 2. `eliza/packages/app-core/scripts/run-node.mjs` (child process env)
```js
const rootModules = path.join(cwd, "node_modules");
env.NODE_PATH = ...;
```
**Why here:** The CLI runner spawns a child process that runs `eliza.mjs``dist/entry.js``dist/eliza.js`. Setting `NODE_PATH` in the child's env ensures the child resolves from root even though `dist/` doesn't have its own `node_modules`.
### 3. `eliza/packages/app-core/platforms/electrobun/src/native/agent.ts` (Electrobun native runtime)
```ts
// Dev: walk up from __dirname to find node_modules
// Packaged: use ASAR node_modules
```
**Why here:** The Electrobun native runtime loads `eliza-dist/eliza.js` via `dynamicImport()`. In dev mode, `__dirname` is deep inside `packages/app-core/platforms/electrobun/build/src/native/` — we walk up to find the first `node_modules` directory (the monorepo root). In packaged mode, we use the ASAR's `node_modules` instead.
## Why not just use the bundler?
tsdown with `noExternal: [/.*/]` inlines most dependencies, but `@elizaos/plugin-*` packages are loaded via **runtime dynamic import** (the plugin name comes from config, not a static import). The bundler can't inline them because it doesn't know which plugins will be loaded. They must be resolvable at runtime.
## Packaged App: Skipped Branch
In the packaged `.app`, `eliza.js` lives at `app.asar.unpacked/eliza-dist/eliza.js`. Two levels up is `Contents/Resources/` — no `node_modules` there. The `existsSync` check in `eliza.ts` returns false, so the NODE_PATH code is skipped entirely. The packaged app instead copies runtime packages into `eliza-dist/node_modules` during the desktop build (`copy-runtime-node-modules.ts` for Electrobun) and `agent.ts` sets that packaged `node_modules` directory on `NODE_PATH`.
## Bun and published package exports
Some `@elizaos` packages (e.g. `@elizaos/plugin-sql`) publish a `package.json` with `exports["."].bun = "./src/index.ts"`. **Why they do that:** In the upstream monorepo, Bun can run TypeScript directly, so pointing to `src/` avoids a build step. The published npm tarball, however, only includes `dist/``src/` is not shipped. When we install from npm, the `"bun"` condition points to a path that does not exist.
**What happens:** Bun's resolver prefers the `"bun"` export condition. It tries to load `./src/index.ts`, the file is missing, and we get "Cannot find module … from …/src/runtime/eliza.ts" even though the package is in `node_modules`. Bun does not fall back to the `"import"` condition when the `"bun"` target is missing.
**Our fix:** `packages/app-core/scripts/patch-deps.mjs` runs as part of
`packages/app-core/scripts/run-repo-setup.mjs`. It finds affected `@elizaos`
packages and, if `exports["."].bun` points to `./src/index.ts` and that file
does not exist, removes the `"bun"` and `"default"` conditions that reference
`src/`. After the patch, only `"import"` and similar built-output conditions
remain, so Bun resolves to `./dist/index.js`. In a development workspace where
the plugin is checked out with `src/` present, the package is left unchanged.
## Pinned: `@elizaos/plugin-openrouter`
This repo currently resolves **`@elizaos/plugin-openrouter`** via a local
workspace link (**`workspace:*`**) during development. When not using the local
checkout, the root `package.json` pins **`2.0.0-alpha.13`** (the current known-good
npm tarball). **`2.0.0-alpha.12`** shipped broken dist entrypoints and must be avoided.
### What went wrong in `2.0.0-alpha.12`
The published npm tarball for **`2.0.0-alpha.12`** contains **truncated** JavaScript outputs for the Node ESM and browser entrypoints (`dist/node/index.node.js`, `dist/browser/index.browser.js`). Those files only include the bundled `utils/config` helpers (~80 lines). The **main plugin implementation** (the object that should be exported as `openrouterPlugin` and as `default`) is **not present** in the file, but the final `export { … }` list still names `openrouterPlugin` and `openrouterPlugin2 as default`.
**Why Bun errors:** When the runtime loads the plugin, Bun builds/transpiles that entry file and fails with errors like *`openrouterPlugin` is not declared in this file* — the symbols are exported but never defined. The CommonJS build (`dist/cjs/index.node.cjs`) is truncated in the same way (export getters reference a missing `import_plugin` chunk).
**Why we do not postinstall-patch the dist:** The broken release is missing the
entire plugin body, not a single wrong identifier (contrast
`@elizaos/plugin-pdf`, where a small string replace fixes a bad export alias).
Reconstructing the plugin from source inside Eliza would fork upstream and be
fragile. When you are not using the local workspace checkout, prefer the known
good published **`2.0.0-alpha.13`** artifact.
### Maintainer notes
- **Before bumping** the OpenRouter dependency, verify the **published tarball** on npm: open `dist/node/index.node.js` and confirm it defines the default export / `openrouterPlugin`, or run `bun build node_modules/@elizaos/plugin-openrouter/dist/node/index.node.js --target=bun` after install.
- **Do not replace the workspace link with an unfenced semver range** until upstream publishes a fixed version and you have confirmed the artifact. **Why:** `^2.0.0-alpha.10` allowed Bun to resolve **`alpha.12`**, which broke installs that upgraded the lockfile.
User-facing context and configuration for OpenRouter itself live in **[OpenRouter plugin](plugins/overview)** (Mintlify: `/plugins/overview`).
## Optional plugins: why was this package in the load set?
Optional plugins (and some core-adjacent packages) can end up in the load set because of **`plugins.allow`**, **`plugins.entries`**, **connector** configuration, **`features.*`**, **environment variables** (e.g. provider API keys or wallet keys that trigger auto-enable), or **`plugins.installs`**. When resolution fails with **missing npm module** or **missing browser stagehand**, the log used to look like a generic runtime error.
**Why we record provenance:** `collectPluginNames()` optionally fills a **`PluginLoadReasons`** map (first source wins per package). `resolvePlugins()` passes it through; benign optional failures are summarized as **`Optional plugins not installed: … (added by: …)`**. That answers “what should I change?” — edit config, unset env, install the package, or add a plugin checkout — instead of chasing a false “eliza is broken” hypothesis.
**Browser / stagehand:** `@elizaos/plugin-browser` expects a **stagehand-server** tree that is **not** in the npm tarball. Eliza discovers `plugins/plugin-browser/stagehand-server` by **walking parents** from the runtime so both flat Eliza checkouts and **`eliza/` submodule** layouts resolve. See **[Developer diagnostics and workspace](/apps/desktop-local-development)**.
## Pack-and-test and vendored workspace validation
As part of the Plugin Workspace architecture, we load dependencies via `workspace:*` out of the vendored source tree (`eliza/packages/*` and `eliza/plugins/*`). Sometimes, you need to verify that what works in a `workspace:*` context will successfully pack into tarballs and install strictly downstream as if published.
We provide two scripts to validate and prevent drift:
### `packages/app-core/scripts/pack-upstreams.mjs`
To simulate a real publish release locally, run `node packages/app-core/scripts/pack-upstreams.mjs`. It iterates over the target packages, builds them when needed, runs `npm pack`, and places the resulting `.tgz` artifacts in the root `artifacts/` directory.
### `packages/app-core/scripts/check-upstream-drift.mjs`
To ensure that root-level explicitly pinned dependencies (e.g.,
`"@elizaos/plugin-openrouter": "2.0.0-alpha.13"`) do not drift from source, run
`node packages/app-core/scripts/check-upstream-drift.mjs`. The command inspects
root pins against the `package.json` inside local vendor trees and fails if their
explicitly pinned specifications diverge from source.
### Vendored Source Verification (Proof of Life)
Because all packages resolve via `workspace:*`, local modifications are live the
moment you restart `bun run dev`. No `npm link`, custom `NODE_PATH` patches, or
cache-busting are required for workspace packages.