From f6db86f9a0ee2d5e6078caa1478273774e6ebd35 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 08:35:36 +0100 Subject: [PATCH] fix: normalize lazy service override imports --- CHANGELOG.md | 1 + src/plugins/import-specifier.test.ts | 37 +++++++++++++++++++++++++ src/plugins/import-specifier.ts | 22 +++++++++++++++ src/plugins/lazy-service-module.test.ts | 36 +++++++++++++++++++++++- src/plugins/lazy-service-module.ts | 12 ++++++-- src/plugins/loader.ts | 27 +----------------- 6 files changed, 106 insertions(+), 29 deletions(-) create mode 100644 src/plugins/import-specifier.test.ts create mode 100644 src/plugins/import-specifier.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fc966249f3b..c0fbaac3ff9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - macOS Gateway: detect installed-but-unloaded LaunchAgent split-brain states during status, doctor, and restart, and re-bootstrap launchd supervision before falling back to unmanaged listener restarts. Fixes #67335, #53475, and #71060; refs #58890, #60885, and #70801. Thanks @ze1tgeist88, @dafacto, and @vishutdhar. - Plugins/install: treat mirrored core logger dependencies as staged bundled runtime deps so packaged Gateway starts do not crash when the external plugin-runtime-deps root is missing `tslog`. Fixes #72228; supersedes #72493. Thanks @deepujain. - Plugins/install: hide bundled runtime-dependency npm child windows on Windows across Gateway startup, postinstall, and packaged staging paths so Telegram/Anthropic dependency repair no longer flashes shell windows. Fixes #72315. Thanks @athuljayaram and @joshfeng. +- Plugins/Windows: normalize lazy plugin service override imports before Node ESM loading so drive-letter browser-control module paths no longer fail with `ERR_UNSUPPORTED_ESM_URL_SCHEME`. Fixes #72573; supersedes #72599 and #72582. Thanks @llzzww316, @feineryonah-byte, and @WuKongAI-CMU. - Plugins/install: stage bundled plugin runtime dependencies before Gateway startup, drain update restarts, and materialize plugin-owned root chunks in external mirrors so staged deps resolve under native ESM. Fixes #72058; supersedes #72084. Thanks @amnesia106 and @drvoss. - TTS/SecretRef: resolve `messages.tts.providers.*.apiKey` from the active runtime snapshot so SecretRef-backed MiniMax and other TTS provider keys work in runtime reply/audio paths. Fixes #68690. Thanks @joshavant. - Gateway/install: surface systemd user-bus recovery hints during Linux service activation and retry via the machine user scope when `systemctl --user` reports no-medium bus failures. Fixes #39673; refs #44417 and #63561. Thanks @Arbor4, @myrsu, and @mssteuer. diff --git a/src/plugins/import-specifier.test.ts b/src/plugins/import-specifier.test.ts new file mode 100644 index 00000000000..c154326c8d2 --- /dev/null +++ b/src/plugins/import-specifier.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { toSafeImportPath } from "./import-specifier.js"; + +describe("toSafeImportPath", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("converts Windows absolute import specifiers to file URLs", () => { + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + + expect(toSafeImportPath("C:\\Users\\alice\\plugin\\index.mjs")).toBe( + "file:///C:/Users/alice/plugin/index.mjs", + ); + expect(toSafeImportPath("\\\\server\\share\\plugin\\index.mjs")).toBe( + "file://server/share/plugin/index.mjs", + ); + }); + + it("preserves import specifiers that Node can already resolve", () => { + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + + expect(toSafeImportPath("file:///C:/Users/alice/plugin/index.mjs")).toBe( + "file:///C:/Users/alice/plugin/index.mjs", + ); + expect(toSafeImportPath("./relative/index.mjs")).toBe("./relative/index.mjs"); + expect(toSafeImportPath("@openclaw/plugin")).toBe("@openclaw/plugin"); + }); + + it("does not rewrite non-Windows paths", () => { + vi.spyOn(process, "platform", "get").mockReturnValue("linux"); + + expect(toSafeImportPath("C:\\Users\\alice\\plugin\\index.mjs")).toBe( + "C:\\Users\\alice\\plugin\\index.mjs", + ); + }); +}); diff --git a/src/plugins/import-specifier.ts b/src/plugins/import-specifier.ts new file mode 100644 index 00000000000..c7433c1a0e9 --- /dev/null +++ b/src/plugins/import-specifier.ts @@ -0,0 +1,22 @@ +import path from "node:path"; + +/** + * On Windows, Node's ESM loader requires absolute paths to be expressed as + * file:// URLs. Raw drive-letter paths like C:\... are parsed as URL schemes. + */ +export function toSafeImportPath(specifier: string): string { + if (process.platform !== "win32") { + return specifier; + } + if (specifier.startsWith("file://")) { + return specifier; + } + if (path.win32.isAbsolute(specifier)) { + const normalizedSpecifier = specifier.replaceAll("\\", "/"); + if (normalizedSpecifier.startsWith("//")) { + return new URL(`file:${encodeURI(normalizedSpecifier)}`).href; + } + return new URL(`file:///${encodeURI(normalizedSpecifier)}`).href; + } + return specifier; +} diff --git a/src/plugins/lazy-service-module.test.ts b/src/plugins/lazy-service-module.test.ts index 90ea5ece26d..62669785f83 100644 --- a/src/plugins/lazy-service-module.test.ts +++ b/src/plugins/lazy-service-module.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { startLazyPluginServiceModule } from "./lazy-service-module.js"; +import { defaultLoadOverrideModule, startLazyPluginServiceModule } from "./lazy-service-module.js"; function createAsyncHookMock() { return vi.fn(async () => {}); @@ -89,6 +89,40 @@ describe("startLazyPluginServiceModule", () => { expect(start).toHaveBeenCalledTimes(1); }); + it("normalizes Windows absolute paths in the default override loader", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const start = createAsyncHookMock(); + const importModule = vi.fn(async () => ({ startOverride: start })); + + try { + await defaultLoadOverrideModule("C:\\Users\\alice\\browser-service.mjs", importModule); + } finally { + platformSpy.mockRestore(); + } + + expect(importModule).toHaveBeenCalledWith("file:///C:/Users/alice/browser-service.mjs"); + }); + + it("leaves caller-supplied override loaders responsible for their own specifiers", async () => { + process.env.OPENCLAW_LAZY_SERVICE_OVERRIDE = "C:\\Users\\alice\\browser-service.mjs"; + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const start = createAsyncHookMock(); + const loadOverrideModule = vi.fn(async () => ({ startOverride: start })); + + try { + await expectLifecycleStarted({ + overrideEnvVar: "OPENCLAW_LAZY_SERVICE_OVERRIDE", + loadOverrideModule, + startExportNames: ["startOverride"], + }); + } finally { + platformSpy.mockRestore(); + } + + expect(loadOverrideModule).toHaveBeenCalledWith("C:\\Users\\alice\\browser-service.mjs"); + expect(start).toHaveBeenCalledTimes(1); + }); + it("validates the override specifier before loading it", async () => { process.env.OPENCLAW_LAZY_SERVICE_OVERRIDE = "virtual:service"; const loadOverrideModule = vi.fn(async () => ({ startOverride: createAsyncHookMock() })); diff --git a/src/plugins/lazy-service-module.ts b/src/plugins/lazy-service-module.ts index 3fa7e11dc28..120e5f58a62 100644 --- a/src/plugins/lazy-service-module.ts +++ b/src/plugins/lazy-service-module.ts @@ -1,4 +1,5 @@ import { isTruthyEnvValue } from "../infra/env.js"; +import { toSafeImportPath } from "./import-specifier.js"; type LazyServiceModule = Record; @@ -17,6 +18,14 @@ function resolveExport(mod: LazyServiceModule, names: string[]): T | null { return null; } +export async function defaultLoadOverrideModule( + specifier: string, + importModule: (specifier: string) => Promise = async (source: string) => + await import(source), +): Promise { + return importModule(toSafeImportPath(specifier)); +} + export async function startLazyPluginServiceModule(params: { skipEnvVar?: string; overrideEnvVar?: string; @@ -33,8 +42,7 @@ export async function startLazyPluginServiceModule(params: { const overrideEnvVar = params.overrideEnvVar?.trim(); const override = overrideEnvVar ? process.env[overrideEnvVar]?.trim() : undefined; - const loadOverrideModule = - params.loadOverrideModule ?? (async (specifier: string) => await import(specifier)); + const loadOverrideModule = params.loadOverrideModule ?? defaultLoadOverrideModule; const validatedOverride = override && params.validateOverrideSpecifier ? params.validateOverrideSpecifier(override) diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index d78f25948b9..9258271f927 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -66,6 +66,7 @@ import { } from "./config-state.js"; import { discoverOpenClawPlugins } from "./discovery.js"; import { getGlobalHookRunner, initializeGlobalHookRunner } from "./hook-runner-global.js"; +import { toSafeImportPath } from "./import-specifier.js"; import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-records.js"; import { clearPluginInteractiveHandlers, @@ -429,32 +430,6 @@ function runPluginRegisterSync( } } -/** - * On Windows, the Node.js ESM loader requires absolute paths to be expressed - * as file:// URLs (e.g. file:///C:/Users/...). Raw drive-letter paths like - * C:\... are rejected with ERR_UNSUPPORTED_ESM_URL_SCHEME because the loader - * mistakes the drive letter for an unknown URL scheme. - * - * This helper converts Windows absolute import specifiers to file:// URLs and - * leaves everything else unchanged. - */ -function toSafeImportPath(specifier: string): string { - if (process.platform !== "win32") { - return specifier; - } - if (specifier.startsWith("file://")) { - return specifier; - } - if (path.win32.isAbsolute(specifier)) { - const normalizedSpecifier = specifier.replaceAll("\\", "/"); - if (normalizedSpecifier.startsWith("//")) { - return new URL(`file:${encodeURI(normalizedSpecifier)}`).href; - } - return new URL(`file:///${encodeURI(normalizedSpecifier)}`).href; - } - return specifier; -} - type RuntimeDependencyPackageJson = { dependencies?: Record; optionalDependencies?: Record;