fix(agents): normalize Windows runtime imports (#72731)

* fix(agents): normalize Windows runtime imports

* test(providers): align manifest contract coverage
This commit is contained in:
Peter Steinberger
2026-04-27 10:34:25 +01:00
committed by GitHub
parent 8b85f2c163
commit cf499101a2
10 changed files with 160 additions and 25 deletions

View File

@@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai
- 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.
- Build/plugins: preserve active bundled runtime-dependency staging temp directories owned by live build processes so overlapping postbuild runs no longer delete each other's staged deps mid-prune. Supersedes #72220. Thanks @VACInc.
- 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.
- Agents/Windows: normalize lazy agent runtime imports before Node ESM loading so Windows drive-letter `subagent-registry` runtime paths no longer fail every agent task with `ERR_UNSUPPORTED_ESM_URL_SCHEME`. Fixes #72636; carries forward #72716. Thanks @Andyz-CData and @xialonglee.
- 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.
- Browser/plugins: load `playwright-core` through the browser runtime shim so packaged installs can run Playwright actions from staged plugin runtime deps after doctor/startup repair. Fixes #72168; supersedes #72238. Thanks @zdg1110 and @yetval.
- 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.

View File

@@ -228,7 +228,7 @@ gh workflow run duplicate-after-merge.yml \
| `checks-node-compat-node22` | Node 22 compatibility build and smoke lane | Manual CI dispatch for releases |
| `check-docs` | Docs formatting, lint, and broken-link checks | Docs changed |
| `skills-python` | Ruff + pytest for Python-backed skills | Python-skill-relevant changes |
| `checks-windows` | Windows-specific test lanes | Windows-relevant changes |
| `checks-windows` | Windows-specific process/path tests plus shared runtime import specifier regressions | Windows-relevant changes |
| `macos-node` | macOS TypeScript test lane using the shared built artifacts | macOS-relevant changes |
| `macos-swift` | Swift lint, build, and tests for the macOS app | macOS-relevant changes |
| `android` | Android unit tests for both flavors plus one debug APK build | Android-relevant changes |

View File

@@ -1611,7 +1611,7 @@
"test:unit:fast:audit": "node scripts/test-unit-fast-audit.mjs",
"test:voicecall:closedloop": "node scripts/test-voicecall-closedloop.mjs",
"test:watch": "node scripts/test-projects.mjs --watch",
"test:windows:ci": "node scripts/test-projects.mjs src/process/exec.windows.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts extensions/lobster/src/lobster-runner.test.ts test/scripts/npm-runner.test.ts test/scripts/pnpm-runner.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
"test:windows:ci": "node scripts/test-projects.mjs src/shared/runtime-import.test.ts src/plugins/import-specifier.test.ts src/process/exec.windows.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts extensions/lobster/src/lobster-runner.test.ts test/scripts/npm-runner.test.ts test/scripts/pnpm-runner.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
"tool-display:check": "node --import tsx scripts/tool-display.ts --check",
"tool-display:write": "node --import tsx scripts/tool-display.ts --write",
"ts-topology": "node --import tsx scripts/ts-topology.ts",

View File

@@ -36,9 +36,9 @@ const ANDROID_NATIVE_RE = /^(apps\/android\/|apps\/shared\/)/;
const NODE_SCOPE_RE =
/^(src\/|test\/|extensions\/|packages\/|scripts\/|ui\/|\.github\/|openclaw\.mjs$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsconfig.*\.json$|vitest.*\.ts$|tsdown\.config\.ts$|\.oxlintrc\.json$|\.oxfmtrc\.jsonc$)/;
const WINDOWS_SCOPE_RE =
/^(src\/process\/|src\/infra\/windows-install-roots\.ts$|scripts\/(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.(?:mjs|js)$|test\/scripts\/(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.test\.ts$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|\.github\/workflows\/ci\.yml$|\.github\/actions\/setup-node-env\/action\.yml$|\.github\/actions\/setup-pnpm-store-cache\/action\.yml$)/;
/^(src\/process\/|src\/infra\/windows-install-roots\.ts$|src\/plugins\/import-specifier(?:\.test)?\.ts$|src\/shared\/(?:import-specifier|runtime-import)(?:\.test)?\.ts$|scripts\/(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.(?:mjs|js)$|test\/scripts\/(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.test\.ts$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|\.github\/workflows\/ci\.yml$|\.github\/actions\/setup-node-env\/action\.yml$|\.github\/actions\/setup-pnpm-store-cache\/action\.yml$)/;
const WINDOWS_TEST_SCOPE_RE =
/^(src\/process\/(?:exec\.windows|windows-command)\.test\.ts$|src\/infra\/windows-install-roots\.test\.ts$|test\/scripts\/(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.test\.ts$)/;
/^(src\/process\/(?:exec\.windows|windows-command)\.test\.ts$|src\/infra\/windows-install-roots\.test\.ts$|src\/plugins\/import-specifier\.test\.ts$|src\/shared\/runtime-import\.test\.ts$|test\/scripts\/(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.test\.ts$)/;
const TEST_ONLY_PATH_RE =
/(^test\/|\/test\/|\/tests\/|(?:^|\/)[^/]+\.(?:test|spec|test-utils|test-support|test-harness|e2e-harness)\.[cm]?[jt]sx?$)/;
const CONTROL_UI_I18N_SCOPE_RE =

View File

@@ -1,19 +1 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
/**
* 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)) {
return pathToFileURL(specifier, { windows: true }).href;
}
return specifier;
}
export { toSafeImportPath } from "../shared/import-specifier.js";

View File

@@ -247,6 +247,33 @@ describe("detectChangedScope", () => {
runChangedSmoke: false,
runControlUiI18n: false,
});
expect(detectChangedScope(["src/shared/runtime-import.ts"])).toEqual({
runNode: true,
runMacos: false,
runAndroid: false,
runWindows: true,
runSkillsPython: false,
runChangedSmoke: false,
runControlUiI18n: false,
});
expect(detectChangedScope(["src/shared/runtime-import.test.ts"])).toEqual({
runNode: true,
runMacos: false,
runAndroid: false,
runWindows: true,
runSkillsPython: false,
runChangedSmoke: false,
runControlUiI18n: false,
});
expect(detectChangedScope(["src/plugins/import-specifier.test.ts"])).toEqual({
runNode: true,
runMacos: false,
runAndroid: false,
runWindows: true,
runSkillsPython: false,
runChangedSmoke: false,
runControlUiI18n: false,
});
expect(detectChangedScope(["scripts/npm-runner.mjs"])).toEqual({
runNode: true,
runMacos: false,

View File

@@ -0,0 +1,19 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
/**
* 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)) {
return pathToFileURL(specifier, { windows: true }).href;
}
return specifier;
}

View File

@@ -0,0 +1,82 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
importRuntimeModule,
resolveRuntimeImportSpecifier,
toSafeRuntimeImportPath,
} from "./runtime-import.js";
describe("runtime-import", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("converts Windows absolute import specifiers to file URLs", () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
expect(toSafeRuntimeImportPath("C:\\Users\\alice\\plugin\\index.mjs")).toBe(
"file:///C:/Users/alice/plugin/index.mjs",
);
expect(toSafeRuntimeImportPath("C:\\Users\\alice\\plugin folder\\x#y.mjs")).toBe(
"file:///C:/Users/alice/plugin%20folder/x%23y.mjs",
);
expect(toSafeRuntimeImportPath("\\\\server\\share\\plugin\\index.mjs")).toBe(
"file://server/share/plugin/index.mjs",
);
});
it("resolves runtime imports from Windows absolute base paths", () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
expect(
resolveRuntimeImportSpecifier("C:\\Users\\alice\\openclaw\\dist\\subagent-registry.js", [
"./subagent-registry.runtime.js",
]),
).toBe("file:///C:/Users/alice/openclaw/dist/subagent-registry.runtime.js");
});
it("resolves runtime imports from file URL base paths", () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
expect(
resolveRuntimeImportSpecifier("file:///C:/Users/alice/openclaw/dist/subagent-registry.js", [
"./subagent-registry.runtime.js",
]),
).toBe("file:///C:/Users/alice/openclaw/dist/subagent-registry.runtime.js");
});
it("resolves absolute Windows runtime import parts directly", () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
expect(
resolveRuntimeImportSpecifier("file:///C:/Users/alice/openclaw/dist/subagent-registry.js", [
"D:\\OpenClaw\\dist\\subagent-registry.runtime.js",
]),
).toBe("file:///D:/OpenClaw/dist/subagent-registry.runtime.js");
});
it("keeps non-Windows import paths unchanged", () => {
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
expect(toSafeRuntimeImportPath("C:\\Users\\alice\\plugin\\index.mjs")).toBe(
"C:\\Users\\alice\\plugin\\index.mjs",
);
});
it("imports with the normalized runtime specifier", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const importModule = vi.fn(async (specifier: string) => ({ specifier }));
const result = await importRuntimeModule(
"C:\\Users\\alice\\openclaw\\dist\\subagent-registry.js",
["./subagent-registry.runtime.js"],
importModule,
);
expect(importModule).toHaveBeenCalledWith(
"file:///C:/Users/alice/openclaw/dist/subagent-registry.runtime.js",
);
expect(result).toEqual({
specifier: "file:///C:/Users/alice/openclaw/dist/subagent-registry.runtime.js",
});
});
});

View File

@@ -1,6 +1,20 @@
import { toSafeImportPath } from "./import-specifier.js";
export { toSafeImportPath as toSafeRuntimeImportPath } from "./import-specifier.js";
export function resolveRuntimeImportSpecifier(baseUrl: string, parts: readonly string[]): string {
const joined = parts.join("");
const safeJoined = toSafeImportPath(joined);
if (safeJoined !== joined) {
return safeJoined;
}
return new URL(joined, toSafeImportPath(baseUrl)).href;
}
export async function importRuntimeModule<T>(
baseUrl: string,
parts: readonly string[],
importModule: (specifier: string) => Promise<unknown> = (specifier) => import(specifier),
): Promise<T> {
return (await import(new URL(parts.join(""), baseUrl).href)) as T;
return (await importModule(resolveRuntimeImportSpecifier(baseUrl, parts))) as T;
}

View File

@@ -12,6 +12,14 @@ type ProviderContractEntry = {
provider: ProviderPlugin;
};
function providerMatchesManifestId(provider: ProviderPlugin, providerId: string): boolean {
return (
provider.id === providerId ||
(provider.aliases ?? []).includes(providerId) ||
(provider.hookAliases ?? []).includes(providerId)
);
}
function resolveProviderContractProvidersFromPublicArtifact(
pluginId: string,
): ProviderContractEntry[] | null {
@@ -46,7 +54,9 @@ export function describeProviderContracts(pluginId: string) {
// does not race provider contract collection against other file imports.
installProviderPluginContractSuite({
provider: () => {
const entry = resolveProviderEntries().find((entry) => entry.provider.id === providerId);
const entry = resolveProviderEntries().find((entry) =>
providerMatchesManifestId(entry.provider, providerId),
);
if (!entry) {
throw new Error(`provider contract entry missing for ${pluginId}:${providerId}`);
}