diff --git a/server/src/__tests__/plugin-tsx-loader-condition.test.ts b/server/src/__tests__/plugin-tsx-loader-condition.test.ts new file mode 100644 index 0000000000..d3629fef4c --- /dev/null +++ b/server/src/__tests__/plugin-tsx-loader-condition.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import path from "node:path"; +import { isRepoLocalPluginPath, REPO_ROOT } from "../services/plugin-loader.js"; + +describe("isRepoLocalPluginPath", () => { + it("returns true for a package inside the repository", () => { + const pkgPath = path.join(REPO_ROOT, "packages", "plugins", "examples", "plugin-hello-world-example"); + expect(isRepoLocalPluginPath(pkgPath)).toBe(true); + }); + + it("returns true when the package path equals the repository root", () => { + expect(isRepoLocalPluginPath(REPO_ROOT)).toBe(true); + }); + + it("returns false for a package outside the repository", () => { + const outside = path.join(REPO_ROOT, "..", "external-plugin"); + expect(isRepoLocalPluginPath(outside)).toBe(false); + }); + + it("returns false for a sibling directory that merely shares the repo name prefix", () => { + const sibling = path.join(path.dirname(REPO_ROOT), path.basename(REPO_ROOT) + "-external"); + expect(isRepoLocalPluginPath(sibling)).toBe(false); + }); + + it("returns false for null and undefined", () => { + expect(isRepoLocalPluginPath(null)).toBe(false); + expect(isRepoLocalPluginPath(undefined)).toBe(false); + }); + + it("returns false for an empty string", () => { + expect(isRepoLocalPluginPath("")).toBe(false); + }); +}); diff --git a/server/src/services/plugin-loader.ts b/server/src/services/plugin-loader.ts index a72e5db2ef..aba2377d9b 100644 --- a/server/src/services/plugin-loader.ts +++ b/server/src/services/plugin-loader.ts @@ -703,6 +703,19 @@ function isPathWithin(root: string, target: string): boolean { return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); } +/** + * True when a plugin package path is inside this repository. + * + * The dev tsx loader is only applied to repo-local packages, because + * external plugins ship prebuilt JS and tsx's `--import` loader crashes + * on Windows when the worker entry is an absolute drive-letter path + * (ERR_UNSUPPORTED_ESM_URL_SCHEME, protocol 'e:'). + */ +export function isRepoLocalPluginPath(packagePath: string | null | undefined): boolean { + if (!packagePath) return false; + return isPathWithin(REPO_ROOT, path.resolve(packagePath)); +} + export function isRepoBundledPluginPath( packageRoot: string, options: { repoRoot?: string } = {}, @@ -2325,13 +2338,24 @@ export function pluginLoader( proactiveCompanyScopes: configRows.map((row) => row.companyId), }; - // Repo-local plugin installs can resolve workspace TS sources at runtime - // (for example @paperclipai/shared exports). Run those workers through - // the tsx loader so first-party example plugins work in development. - if (activePlugin.packagePath && existsSync(DEV_TSX_LOADER_PATH)) { + // The tsx loader is only needed for repo-local packages that may + // import workspace TypeScript sources (e.g. @paperclipai/shared) + // at runtime. Restricting it to packages inside this repo also fixes + // a Windows crash for external local-path plugin installs: with + // `--import `, Node's ESM loader receives the worker + // entry as a raw drive-letter path (e.g. `E:\plugins\dist\worker.js`) + // and fails with ERR_UNSUPPORTED_ESM_URL_SCHEME (protocol 'e:'). + // External plugins ship prebuilt JS and do not need the tsx loader. + const resolvedPkgPath = activePlugin.packagePath + ? path.resolve(activePlugin.packagePath) + : null; + if ( + resolvedPkgPath !== null && + isRepoLocalPluginPath(resolvedPkgPath) && + existsSync(DEV_TSX_LOADER_PATH) + ) { workerOptions.execArgv = ["--import", DEV_TSX_LOADER_PATH]; } - await workerManager.startWorker(pluginId, workerOptions); registered.worker = true;