From 1de4aff06dddc5c972a624f0237f0de7f30b2c10 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 08:13:58 +0100 Subject: [PATCH] fix: cover Windows pnpm and Lobster install regressions --- CHANGELOG.md | 1 + extensions/lobster/src/lobster-runner.test.ts | 60 ++++++++++++- extensions/lobster/src/lobster-runner.ts | 84 +++++++++++++++++-- package.json | 2 +- scripts/pnpm-runner.mjs | 47 ++++++++--- test/scripts/pnpm-runner.test.ts | 61 ++++++++++++++ 6 files changed, 232 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c07add0a7ad..2c1239c5d7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,7 @@ Docs: https://docs.openclaw.ai canonical Anthropic models through `claude-cli` without passing CLI backend aliases to embedded harness selection. Fixes #71957. Thanks @WolvenRA. - CLI/update: guard Windows scheduled-task stops by state and timeout so auto-update restart cannot hang indefinitely on `schtasks /End` before stale-listener cleanup. Fixes #69970. Thanks @yangswld and @sherlock-huang. +- Windows install/Lobster: execute `pnpm.exe` directly when `npm_execpath` points at the native pnpm binary, add an installed-package fallback for the Lobster embedded runtime, and include the Lobster runner regression test in Windows CI. Fixes #69456. Thanks @igormf. - Gateway/install: refresh loaded gateway service installs when the current service embeds stale gateway auth instead of returning already-installed, avoiding LaunchAgent token-mismatch loops after token rotation. Fixes #70752. Thanks @hyspacex. - Update: ignore bundled plugin `.openclaw-install-stage` directories during global install verification and packaged dist pruning so leftover runtime-dep staging files do not turn successful updates into `unexpected packaged dist file` failures. Fixes #71752. Thanks @waynegault. - Node runtime: keep node-host retry timers alive across Gateway restarts and exit on terminal credential pauses so supervised nodes do not become silent zombies. Fixes #69800. Thanks @meroli28. diff --git a/extensions/lobster/src/lobster-runner.test.ts b/extensions/lobster/src/lobster-runner.test.ts index 3d48b45bd2a..ff0cf6927ee 100644 --- a/extensions/lobster/src/lobster-runner.test.ts +++ b/extensions/lobster/src/lobster-runner.test.ts @@ -2,7 +2,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createEmbeddedLobsterRunner, resolveLobsterCwd } from "./lobster-runner.js"; +import { + createEmbeddedLobsterRunner, + loadEmbeddedToolRuntimeFromPackage, + resolveLobsterCwd, +} from "./lobster-runner.js"; describe("resolveLobsterCwd", () => { it("defaults to the current working directory", () => { @@ -352,6 +356,60 @@ describe("createEmbeddedLobsterRunner", () => { expect(loadRuntime).toHaveBeenCalledTimes(1); }); + it("falls back to the installed package core file when the core export is unavailable", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-package-")); + const packageRoot = path.join(tempDir, "node_modules", "@clawdbot", "lobster"); + const packageEntryPath = path.join(packageRoot, "dist", "src", "sdk", "index.js"); + const packageCorePath = path.join(packageRoot, "dist", "src", "core", "index.js"); + + try { + await fs.mkdir(path.dirname(packageEntryPath), { recursive: true }); + await fs.mkdir(path.dirname(packageCorePath), { recursive: true }); + await fs.writeFile( + path.join(packageRoot, "package.json"), + JSON.stringify({ + name: "@clawdbot/lobster", + type: "module", + main: "./dist/src/sdk/index.js", + }), + "utf8", + ); + await fs.writeFile(packageEntryPath, "export {};\n", "utf8"); + await fs.writeFile( + packageCorePath, + [ + "export async function runToolRequest() {", + " return { ok: true, status: 'ok', output: [{ source: 'fallback' }], requiresApproval: null };", + "}", + "export async function resumeToolRequest() {", + " return { ok: true, status: 'cancelled', output: [], requiresApproval: null };", + "}", + "", + ].join("\n"), + "utf8", + ); + + const runtime = await loadEmbeddedToolRuntimeFromPackage({ + importModule: async (specifier) => { + if (specifier === "@clawdbot/lobster/core") { + throw new Error("package export missing"); + } + return (await import(`${specifier}?t=${Date.now()}`)) as object; + }, + resolvePackageEntry: () => packageEntryPath, + }); + + await expect(runtime.runToolRequest({ pipeline: "commands.list" })).resolves.toEqual({ + ok: true, + status: "ok", + output: [{ source: "fallback" }], + requiresApproval: null, + }); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it("requires a pipeline for run", async () => { const runner = createEmbeddedLobsterRunner({ loadRuntime: vi.fn().mockResolvedValue({ diff --git a/extensions/lobster/src/lobster-runner.ts b/extensions/lobster/src/lobster-runner.ts index 8d0c891901b..7209b719d60 100644 --- a/extensions/lobster/src/lobster-runner.ts +++ b/extensions/lobster/src/lobster-runner.ts @@ -1,10 +1,9 @@ +import { readFileSync } from "node:fs"; import { stat } from "node:fs/promises"; +import { createRequire } from "node:module"; import path from "node:path"; import { Readable, Writable } from "node:stream"; -import { - resumeToolRequest as embeddedResumeToolRequest, - runToolRequest as embeddedRunToolRequest, -} from "@clawdbot/lobster/core"; +import { pathToFileURL } from "node:url"; export type LobsterEnvelope = | { @@ -97,6 +96,45 @@ type EmbeddedToolRuntime = { type LoadEmbeddedToolRuntime = () => Promise; +type LoadEmbeddedToolRuntimeFromPackageOptions = { + importModule?: (specifier: string) => Promise>; + resolvePackageEntry?: (specifier: string) => string; +}; + +const lobsterRequire = createRequire(import.meta.url); + +function toEmbeddedToolRuntime( + moduleExports: Partial, + source: string, +): EmbeddedToolRuntime { + const { runToolRequest, resumeToolRequest } = moduleExports; + if (typeof runToolRequest === "function" && typeof resumeToolRequest === "function") { + return { runToolRequest, resumeToolRequest }; + } + throw new Error(`${source} does not export Lobster embedded runtime functions`); +} + +function findLobsterPackageRoot(resolvedEntryPath: string): string { + let dir = path.dirname(resolvedEntryPath); + while (true) { + const packageJsonPath = path.join(dir, "package.json"); + try { + const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: string }; + if (parsed.name === "@clawdbot/lobster") { + return dir; + } + } catch { + // Keep walking until the installed package root is found. + } + + const parent = path.dirname(dir); + if (parent === dir) { + throw new Error(`Could not locate @clawdbot/lobster package root from ${resolvedEntryPath}`); + } + dir = parent; + } +} + function normalizeForCwdSandbox(p: string): string { const normalized = path.normalize(p); return process.platform === "win32" ? normalized.toLowerCase() : normalized; @@ -255,11 +293,39 @@ async function withTimeout( }); } -async function loadEmbeddedToolRuntimeFromPackage(): Promise { - return { - runToolRequest: embeddedRunToolRequest, - resumeToolRequest: embeddedResumeToolRequest, - }; +export async function loadEmbeddedToolRuntimeFromPackage( + options: LoadEmbeddedToolRuntimeFromPackageOptions = {}, +): Promise { + const importModule = + options.importModule ?? + (async (specifier: string) => (await import(specifier)) as Partial); + const resolvePackageEntry = + options.resolvePackageEntry ?? ((specifier: string) => lobsterRequire.resolve(specifier)); + + let coreLoadError: unknown; + try { + const coreSpecifier = ["@clawdbot", "lobster", "core"].join("/"); + return toEmbeddedToolRuntime(await importModule(coreSpecifier), "@clawdbot/lobster/core"); + } catch (error) { + coreLoadError = error; + } + + let fallbackLoadError: unknown; + try { + const packageEntryPath = resolvePackageEntry("@clawdbot/lobster"); + const packageRoot = findLobsterPackageRoot(packageEntryPath); + const coreRuntimeUrl = pathToFileURL(path.join(packageRoot, "dist/src/core/index.js")).href; + return toEmbeddedToolRuntime(await importModule(coreRuntimeUrl), coreRuntimeUrl); + } catch (error) { + fallbackLoadError = error; + } + + throw new Error("Failed to load the Lobster embedded runtime", { + cause: new AggregateError( + [coreLoadError, fallbackLoadError], + "Both Lobster embedded runtime load paths failed", + ), + }); } export function createEmbeddedLobsterRunner(options?: { diff --git a/package.json b/package.json index 7358d38c8d1..5ccadca226b 100644 --- a/package.json +++ b/package.json @@ -1593,7 +1593,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 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/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/pnpm-runner.mjs b/scripts/pnpm-runner.mjs index 63abe522aae..6ecdb348790 100644 --- a/scripts/pnpm-runner.mjs +++ b/scripts/pnpm-runner.mjs @@ -3,8 +3,16 @@ import { closeSync, openSync, readSync } from "node:fs"; import path from "node:path"; import { buildCmdExeCommandLine } from "./windows-cmd-helpers.mjs"; +function getPortableBasename(value) { + return value.split(/[/\\]/).at(-1) ?? value; +} + +function getPortableExtension(value) { + return path.posix.extname(getPortableBasename(value)).toLowerCase(); +} + function isPnpmExecPath(value) { - return /^pnpm(?:-cli)?(?:\.(?:[cm]?js|cmd|exe))?$/.test(path.basename(value).toLowerCase()); + return /^pnpm(?:-cli)?(?:\.(?:[cm]?js|cmd|exe))?$/.test(getPortableBasename(value).toLowerCase()); } function hasScriptShebang(value) { @@ -30,7 +38,7 @@ function isNodeRunnablePnpmExecPath(value) { if (!isPnpmExecPath(value)) { return false; } - const extension = path.extname(value).toLowerCase(); + const extension = getPortableExtension(value); if (extension === ".js" || extension === ".cjs" || extension === ".mjs") { return true; } @@ -48,16 +56,31 @@ export function resolvePnpmRunner(params = {}) { const platform = params.platform ?? process.platform; const comSpec = params.comSpec ?? process.env.ComSpec ?? "cmd.exe"; - if ( - typeof npmExecPath === "string" && - npmExecPath.length > 0 && - isNodeRunnablePnpmExecPath(npmExecPath) - ) { - return { - command: nodeExecPath, - args: [...nodeArgs, npmExecPath, ...pnpmArgs], - shell: false, - }; + if (typeof npmExecPath === "string" && npmExecPath.length > 0 && isPnpmExecPath(npmExecPath)) { + if (isNodeRunnablePnpmExecPath(npmExecPath)) { + return { + command: nodeExecPath, + args: [...nodeArgs, npmExecPath, ...pnpmArgs], + shell: false, + }; + } + + const npmExecExtension = getPortableExtension(npmExecPath); + if (platform === "win32" && npmExecExtension === ".exe") { + return { + command: npmExecPath, + args: pnpmArgs, + shell: false, + }; + } + if (platform === "win32" && npmExecExtension === ".cmd") { + return { + command: comSpec, + args: ["/d", "/s", "/c", buildCmdExeCommandLine(npmExecPath, pnpmArgs)], + shell: false, + windowsVerbatimArguments: true, + }; + } } if (platform === "win32") { diff --git a/test/scripts/pnpm-runner.test.ts b/test/scripts/pnpm-runner.test.ts index c27c604c3b2..84216d8caa8 100644 --- a/test/scripts/pnpm-runner.test.ts +++ b/test/scripts/pnpm-runner.test.ts @@ -92,6 +92,67 @@ describe("resolvePnpmRunner", () => { } }); + it("executes pnpm.exe directly on Windows", () => { + const npmExecPath = + "C:\\Users\\test\\AppData\\Local\\pnpm\\.tools\\@pnpm+exe\\10.32.1\\node_modules\\@pnpm\\exe\\pnpm.exe"; + + expect( + resolvePnpmRunner({ + npmExecPath, + nodeArgs: ["--no-maglev"], + nodeExecPath: "C:\\Program Files\\nodejs\\node.exe", + pnpmArgs: ["exec", "vitest", "run"], + platform: "win32", + }), + ).toEqual({ + command: npmExecPath, + args: ["exec", "vitest", "run"], + shell: false, + }); + }); + + it("uses pnpm.cjs through node for Windows-style paths", () => { + expect( + resolvePnpmRunner({ + npmExecPath: + "C:\\Users\\test\\AppData\\Local\\node\\corepack\\v1\\pnpm\\10.32.1\\bin\\pnpm.cjs", + nodeExecPath: "C:\\Program Files\\nodejs\\node.exe", + pnpmArgs: ["exec", "vitest", "run"], + platform: "win32", + }), + ).toEqual({ + command: "C:\\Program Files\\nodejs\\node.exe", + args: [ + "C:\\Users\\test\\AppData\\Local\\node\\corepack\\v1\\pnpm\\10.32.1\\bin\\pnpm.cjs", + "exec", + "vitest", + "run", + ], + shell: false, + }); + }); + + it("wraps an explicit pnpm.cmd path via cmd.exe on Windows", () => { + expect( + resolvePnpmRunner({ + comSpec: "C:\\Windows\\System32\\cmd.exe", + npmExecPath: "C:\\Program Files\\pnpm\\pnpm.cmd", + pnpmArgs: ["exec", "vitest", "run", "-t", "path with spaces"], + platform: "win32", + }), + ).toEqual({ + command: "C:\\Windows\\System32\\cmd.exe", + args: [ + "/d", + "/s", + "/c", + '"C:\\Program Files\\pnpm\\pnpm.cmd" exec vitest run -t "path with spaces"', + ], + shell: false, + windowsVerbatimArguments: true, + }); + }); + it("falls back to bare pnpm on non-Windows when npm_execpath is missing", () => { expect( resolvePnpmRunner({