diff --git a/CHANGELOG.md b/CHANGELOG.md index d410f54bd78..7091ad200ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/ci.md b/docs/ci.md index ff4dec6c8c1..d11515643d0 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -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 | diff --git a/package.json b/package.json index ab76a5772d9..02857590542 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/ci-changed-scope.mjs b/scripts/ci-changed-scope.mjs index 390b2ba6651..f5e9a77a0ba 100644 --- a/scripts/ci-changed-scope.mjs +++ b/scripts/ci-changed-scope.mjs @@ -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 = diff --git a/src/plugins/import-specifier.ts b/src/plugins/import-specifier.ts index ceed20819d2..c0af93f4cee 100644 --- a/src/plugins/import-specifier.ts +++ b/src/plugins/import-specifier.ts @@ -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"; diff --git a/src/scripts/ci-changed-scope.test.ts b/src/scripts/ci-changed-scope.test.ts index c2e52a6e795..f1562e34ed6 100644 --- a/src/scripts/ci-changed-scope.test.ts +++ b/src/scripts/ci-changed-scope.test.ts @@ -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, diff --git a/src/shared/import-specifier.ts b/src/shared/import-specifier.ts new file mode 100644 index 00000000000..ceed20819d2 --- /dev/null +++ b/src/shared/import-specifier.ts @@ -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; +} diff --git a/src/shared/runtime-import.test.ts b/src/shared/runtime-import.test.ts new file mode 100644 index 00000000000..16e19591083 --- /dev/null +++ b/src/shared/runtime-import.test.ts @@ -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", + }); + }); +}); diff --git a/src/shared/runtime-import.ts b/src/shared/runtime-import.ts index 4d38cb7cf0a..5c41f257d9a 100644 --- a/src/shared/runtime-import.ts +++ b/src/shared/runtime-import.ts @@ -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( baseUrl: string, parts: readonly string[], + importModule: (specifier: string) => Promise = (specifier) => import(specifier), ): Promise { - return (await import(new URL(parts.join(""), baseUrl).href)) as T; + return (await importModule(resolveRuntimeImportSpecifier(baseUrl, parts))) as T; } diff --git a/test/helpers/plugins/provider-contract.ts b/test/helpers/plugins/provider-contract.ts index 7b2b6c38250..5379be8a11b 100644 --- a/test/helpers/plugins/provider-contract.ts +++ b/test/helpers/plugins/provider-contract.ts @@ -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}`); }