fix: normalize lazy service override imports

This commit is contained in:
Peter Steinberger
2026-04-27 08:35:36 +01:00
parent 98e7242b53
commit f6db86f9a0
6 changed files with 106 additions and 29 deletions

View File

@@ -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.

View File

@@ -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",
);
});
});

View File

@@ -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;
}

View File

@@ -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() }));

View File

@@ -1,4 +1,5 @@
import { isTruthyEnvValue } from "../infra/env.js";
import { toSafeImportPath } from "./import-specifier.js";
type LazyServiceModule = Record<string, unknown>;
@@ -17,6 +18,14 @@ function resolveExport<T>(mod: LazyServiceModule, names: string[]): T | null {
return null;
}
export async function defaultLoadOverrideModule(
specifier: string,
importModule: (specifier: string) => Promise<LazyServiceModule> = async (source: string) =>
await import(source),
): Promise<LazyServiceModule> {
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)

View File

@@ -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<string, unknown>;
optionalDependencies?: Record<string, unknown>;