From 15e634d50c93429dd7a276c39bfe368ebc7b1525 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 08:35:16 +0100 Subject: [PATCH] fix(plugins): normalize windows override imports --- docs/ci.md | 4 +- scripts/openclaw-cross-os-release-checks.ts | 118 ++++++++++++++++++ src/plugins/import-specifier.test.ts | 3 + src/plugins/import-specifier.ts | 7 +- src/plugins/lazy-service-module.test.ts | 6 +- src/plugins/loader.test.ts | 3 + .../openclaw-cross-os-release-checks.test.ts | 15 +++ 7 files changed, 148 insertions(+), 8 deletions(-) diff --git a/docs/ci.md b/docs/ci.md index 154e6b7787a..ff4dec6c8c1 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -93,7 +93,9 @@ GitHub-native replacement for most Parallels package/update validation, with Telegram proving the same package artifact through the QA live transport. Cross-OS release checks still cover OS-specific onboarding, installer, and platform behavior; package/update product validation should start with Package -Acceptance. +Acceptance. The Windows packaged and installer fresh lanes also verify that an +installed package can import a browser-control override from a raw absolute +Windows path. Package Acceptance has a bounded legacy-compatibility window for already published packages through `2026.4.25`, including `2026.4.25-beta.*`. Those diff --git a/scripts/openclaw-cross-os-release-checks.ts b/scripts/openclaw-cross-os-release-checks.ts index a3efeea12a1..bd9013f0687 100644 --- a/scripts/openclaw-cross-os-release-checks.ts +++ b/scripts/openclaw-cross-os-release-checks.ts @@ -566,6 +566,17 @@ async function runFreshLane(params) { logPath: join(params.logsDir, "fresh-install.log"), }); + let browserOverrideImportStatus = "skipped"; + if (shouldRunWindowsInstalledBrowserOverrideImportSmoke()) { + logLanePhase(lane, "windows-browser-override-import"); + browserOverrideImportStatus = await runInstalledBrowserOverrideImportSmoke({ + lane, + env, + prefixDir: lane.prefixDir, + logPath: join(params.logsDir, "fresh-windows-browser-override-import.log"), + }); + } + logLanePhase(lane, "onboard"); await runOnboard({ lane, @@ -617,6 +628,7 @@ async function runFreshLane(params) { installedCommit: installed.commit, dashboardStatus: "pass", gatewayPort: lane.gatewayPort, + browserOverrideImportStatus, agentOutput: trimForSummary(agent.stdout), }; } finally { @@ -795,6 +807,17 @@ async function runInstallerFreshSuite(params) { const installed = readInstalledMetadataFromCliPath(freshShell.cliPath); verifyInstalledCandidate(installed, params.build); + let browserOverrideImportStatus = "skipped"; + if (shouldRunWindowsInstalledBrowserOverrideImportSmoke()) { + logLanePhase(lane, "windows-browser-override-import"); + browserOverrideImportStatus = await runInstalledBrowserOverrideImportSmoke({ + lane, + env, + prefixDir: resolveInstalledPrefixDirFromCliPath(freshShell.cliPath), + logPath: join(params.logsDir, "installer-fresh-windows-browser-override-import.log"), + }); + } + logLanePhase(lane, "onboard"); await runOnboardWithInstalledCli({ lane, @@ -900,6 +923,7 @@ async function runInstallerFreshSuite(params) { installedCommit: installed.commit, gatewayPort: lane.gatewayPort, dashboardStatus: "pass", + browserOverrideImportStatus, discordStatus, agentOutput: trimForSummary(agent.stdout), }; @@ -2200,6 +2224,100 @@ async function runBundledPluginPostinstall(params) { }); } +export function shouldRunWindowsInstalledBrowserOverrideImportSmoke(platform = process.platform) { + return platform === "win32"; +} + +export function buildInstalledBrowserOverrideImportProbeScript() { + return ` +import { existsSync } from "node:fs"; +import { startLazyPluginServiceModule } from "openclaw/plugin-sdk/browser-node-runtime"; + +const startedPath = process.env.OPENCLAW_BROWSER_OVERRIDE_STARTED_PATH; +const stoppedPath = process.env.OPENCLAW_BROWSER_OVERRIDE_STOPPED_PATH; + +if (!process.env.OPENCLAW_BROWSER_CONTROL_MODULE) { + throw new Error("Missing OPENCLAW_BROWSER_CONTROL_MODULE."); +} +if (!startedPath || !stoppedPath) { + throw new Error("Missing browser override sentinel path env."); +} + +const handle = await startLazyPluginServiceModule({ + overrideEnvVar: "OPENCLAW_BROWSER_CONTROL_MODULE", + validateOverrideSpecifier: (specifier) => specifier, + loadDefaultModule: async () => { + throw new Error("Default browser control service should not load during override probe."); + }, + startExportNames: ["startBrowserControlService"], + stopExportNames: ["stopBrowserControlService"], +}); + +if (!handle) { + throw new Error("Browser control override probe did not return a service handle."); +} +if (!existsSync(startedPath)) { + throw new Error("Browser control override start sentinel was not written."); +} + +await handle.stop(); + +if (!existsSync(stoppedPath)) { + throw new Error("Browser control override stop sentinel was not written."); +} + +console.log("windows browser override import OK"); +`.trim(); +} + +function buildBrowserOverrideProbeServiceModule() { + return ` +import { writeFileSync } from "node:fs"; + +export async function startBrowserControlService() { + writeFileSync(process.env.OPENCLAW_BROWSER_OVERRIDE_STARTED_PATH, "started\\n", "utf8"); +} + +export async function stopBrowserControlService() { + writeFileSync(process.env.OPENCLAW_BROWSER_OVERRIDE_STOPPED_PATH, "stopped\\n", "utf8"); +} +`.trim(); +} + +async function runInstalledBrowserOverrideImportSmoke(params) { + if (!shouldRunWindowsInstalledBrowserOverrideImportSmoke()) { + return "skipped"; + } + + const probeDir = join(params.lane.rootDir, "browser override import probe"); + mkdirSync(probeDir, { recursive: true }); + const overridePath = join(probeDir, "browser override #module.mjs"); + const probePath = join(probeDir, "run browser override probe.mjs"); + const startedPath = join(probeDir, "started.txt"); + const stoppedPath = join(probeDir, "stopped.txt"); + + writeFileSync(overridePath, `${buildBrowserOverrideProbeServiceModule()}\n`, "utf8"); + writeFileSync(probePath, `${buildInstalledBrowserOverrideImportProbeScript()}\n`, "utf8"); + + await runCommand(process.execPath, [probePath], { + cwd: installedPackageRoot(params.prefixDir), + env: { + ...params.env, + OPENCLAW_BROWSER_CONTROL_MODULE: overridePath, + OPENCLAW_BROWSER_OVERRIDE_STARTED_PATH: startedPath, + OPENCLAW_BROWSER_OVERRIDE_STOPPED_PATH: stoppedPath, + }, + logPath: params.logPath, + timeoutMs: 60_000, + }); + + if (!existsSync(startedPath) || !existsSync(stoppedPath)) { + throw new Error("Browser control override import probe did not write both sentinels."); + } + + return "pass"; +} + function ensureLocalNpmShim(lane) { const shimPath = npmShimPath(lane.prefixDir); if (existsSync(shimPath)) { diff --git a/src/plugins/import-specifier.test.ts b/src/plugins/import-specifier.test.ts index c154326c8d2..ebd4e8117b5 100644 --- a/src/plugins/import-specifier.test.ts +++ b/src/plugins/import-specifier.test.ts @@ -12,6 +12,9 @@ describe("toSafeImportPath", () => { expect(toSafeImportPath("C:\\Users\\alice\\plugin\\index.mjs")).toBe( "file:///C:/Users/alice/plugin/index.mjs", ); + expect(toSafeImportPath("C:\\Users\\alice\\plugin folder\\x#y.mjs")).toBe( + "file:///C:/Users/alice/plugin%20folder/x%23y.mjs", + ); expect(toSafeImportPath("\\\\server\\share\\plugin\\index.mjs")).toBe( "file://server/share/plugin/index.mjs", ); diff --git a/src/plugins/import-specifier.ts b/src/plugins/import-specifier.ts index c7433c1a0e9..ceed20819d2 100644 --- a/src/plugins/import-specifier.ts +++ b/src/plugins/import-specifier.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { pathToFileURL } from "node:url"; /** * On Windows, Node's ESM loader requires absolute paths to be expressed as @@ -12,11 +13,7 @@ export function toSafeImportPath(specifier: string): string { 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 pathToFileURL(specifier, { windows: true }).href; } return specifier; } diff --git a/src/plugins/lazy-service-module.test.ts b/src/plugins/lazy-service-module.test.ts index 62669785f83..2922e553f2d 100644 --- a/src/plugins/lazy-service-module.test.ts +++ b/src/plugins/lazy-service-module.test.ts @@ -95,12 +95,14 @@ describe("startLazyPluginServiceModule", () => { const importModule = vi.fn(async () => ({ startOverride: start })); try { - await defaultLoadOverrideModule("C:\\Users\\alice\\browser-service.mjs", importModule); + await defaultLoadOverrideModule("C:\\Users\\alice\\plugin folder\\x#y.mjs", importModule); } finally { platformSpy.mockRestore(); } - expect(importModule).toHaveBeenCalledWith("file:///C:/Users/alice/browser-service.mjs"); + expect(importModule).toHaveBeenCalledWith( + "file:///C:/Users/alice/plugin%20folder/x%23y.mjs", + ); }); it("leaves caller-supplied override loaders responsible for their own specifiers", async () => { diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 11f3fb39246..1e0b3a8e925 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -7338,6 +7338,9 @@ export const runtimeValue = helperValue;`, expect(__testing.toSafeImportPath("C:\\Users\\alice\\plugin\\index.mjs")).toBe( "file:///C:/Users/alice/plugin/index.mjs", ); + expect(__testing.toSafeImportPath("C:\\Users\\alice\\plugin folder\\x#y.mjs")).toBe( + "file:///C:/Users/alice/plugin%20folder/x%23y.mjs", + ); expect(__testing.toSafeImportPath("\\\\server\\share\\plugin\\index.mjs")).toBe( "file://server/share/plugin/index.mjs", ); diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index 5ca2474e1e2..a940509ebf5 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -8,6 +8,7 @@ import { agentOutputHasExpectedOkMarker, buildWindowsDevUpdateToolchainCheckScript, buildWindowsFreshShellVersionCheckScript, + buildInstalledBrowserOverrideImportProbeScript, buildWindowsPathBootstrapScript, canConnectToLoopbackPort, buildDiscordSmokeGuildsConfig, @@ -35,6 +36,7 @@ import { resolveRunnerMatrix, resolveStaticFileContentType, shouldExerciseManagedGatewayLifecycleAfterInstall, + shouldRunWindowsInstalledBrowserOverrideImportSmoke, shouldSkipInstallerDaemonHealthCheck, shouldStopManagedGatewayBeforeManualFallback, shouldRunMainChannelDevUpdate, @@ -289,6 +291,19 @@ describe("scripts/openclaw-cross-os-release-checks", () => { expect(shouldSkipInstallerDaemonHealthCheck("linux")).toBe(false); }); + it("runs the installed browser override import smoke only on native Windows", () => { + expect(shouldRunWindowsInstalledBrowserOverrideImportSmoke("win32")).toBe(true); + expect(shouldRunWindowsInstalledBrowserOverrideImportSmoke("darwin")).toBe(false); + expect(shouldRunWindowsInstalledBrowserOverrideImportSmoke("linux")).toBe(false); + + const script = buildInstalledBrowserOverrideImportProbeScript(); + expect(script).toContain('from "openclaw/plugin-sdk/browser-node-runtime"'); + expect(script).toContain('overrideEnvVar: "OPENCLAW_BROWSER_CONTROL_MODULE"'); + expect(script).toContain("startBrowserControlService"); + expect(script).toContain("stopBrowserControlService"); + expect(script).toContain("Browser control override start sentinel was not written."); + }); + it("normalizes Windows installed CLI paths to the cmd shim", () => { expect( normalizeWindowsInstalledCliPath(