From 02123e591cf34aaa7717f0d7ac64a158696f12eb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 10:44:18 +0100 Subject: [PATCH] refactor(lobster): extract windows spawn resolver --- extensions/lobster/src/lobster-tool.ts | 190 ++++-------------------- extensions/lobster/src/windows-spawn.ts | 176 ++++++++++++++++++++++ 2 files changed, 209 insertions(+), 157 deletions(-) create mode 100644 extensions/lobster/src/windows-spawn.ts diff --git a/extensions/lobster/src/lobster-tool.ts b/extensions/lobster/src/lobster-tool.ts index 4d153dd968..5c46261938 100644 --- a/extensions/lobster/src/lobster-tool.ts +++ b/extensions/lobster/src/lobster-tool.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { Type } from "@sinclair/typebox"; import type { OpenClawPluginApi } from "../../../src/plugins/types.js"; +import { resolveWindowsLobsterSpawn } from "./windows-spawn.js"; type LobsterEnvelope = | { @@ -84,148 +85,6 @@ function resolveCwd(cwdRaw: unknown): string { return resolved; } -function isFilePath(value: string): boolean { - try { - const stat = fs.statSync(value); - return stat.isFile(); - } catch { - return false; - } -} - -function resolveWindowsExecutablePath(execPath: string, env: NodeJS.ProcessEnv): string { - if (execPath.includes("/") || execPath.includes("\\") || path.isAbsolute(execPath)) { - return execPath; - } - const pathValue = env.PATH ?? env.Path ?? process.env.PATH ?? process.env.Path ?? ""; - const pathEntries = pathValue - .split(";") - .map((entry) => entry.trim()) - .filter(Boolean); - const hasExtension = path.extname(execPath).length > 0; - const pathExtRaw = - env.PATHEXT ?? - env.Pathext ?? - process.env.PATHEXT ?? - process.env.Pathext ?? - ".EXE;.CMD;.BAT;.COM"; - const pathExt = hasExtension - ? [""] - : pathExtRaw - .split(";") - .map((ext) => ext.trim()) - .filter(Boolean) - .map((ext) => (ext.startsWith(".") ? ext : `.${ext}`)); - for (const dir of pathEntries) { - for (const ext of pathExt) { - for (const candidateExt of [ext, ext.toLowerCase(), ext.toUpperCase()]) { - const candidate = path.join(dir, `${execPath}${candidateExt}`); - if (isFilePath(candidate)) { - return candidate; - } - } - } - } - return execPath; -} - -function resolveLobsterScriptFromPackageJson(wrapperPath: string): string | null { - const wrapperDir = path.dirname(wrapperPath); - const packageDirs = [ - // Local install: /node_modules/.bin/lobster.cmd -> ../lobster - path.resolve(wrapperDir, "..", "lobster"), - // Global npm install: /lobster.cmd -> ./node_modules/lobster - path.resolve(wrapperDir, "node_modules", "lobster"), - ]; - for (const packageDir of packageDirs) { - const packageJsonPath = path.join(packageDir, "package.json"); - if (!isFilePath(packageJsonPath)) { - continue; - } - try { - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { - bin?: string | Record; - }; - const binField = packageJson.bin; - const scriptRel = - typeof binField === "string" - ? binField - : typeof binField === "object" && binField - ? typeof binField.lobster === "string" - ? binField.lobster - : (() => { - const first = Object.values(binField).find((value) => typeof value === "string"); - return typeof first === "string" ? first : null; - })() - : null; - if (!scriptRel) { - continue; - } - const scriptPath = path.resolve(packageDir, scriptRel); - if (isFilePath(scriptPath)) { - return scriptPath; - } - } catch { - // Ignore malformed package metadata; caller will throw a guided error. - } - } - return null; -} - -function resolveLobsterScriptFromCmdShim(wrapperPath: string): string | null { - if (!isFilePath(wrapperPath)) { - return null; - } - try { - const content = fs.readFileSync(wrapperPath, "utf8"); - // npm-style cmd shims usually reference the script as "%dp0%\\...". - const candidates: string[] = []; - const matches = content.matchAll(/"%~?dp0%\\([^"\r\n]+)"/gi); - for (const match of matches) { - const relative = match[1]; - if (!relative) { - continue; - } - const normalizedRelative = relative.replace(/[\\/]+/g, path.sep); - const candidate = path.resolve(path.dirname(wrapperPath), normalizedRelative); - if (isFilePath(candidate)) { - candidates.push(candidate); - } - } - const nonNode = candidates.find((candidate) => { - const base = path.basename(candidate).toLowerCase(); - return base !== "node.exe" && base !== "node"; - }); - if (nonNode) { - return nonNode; - } - } catch { - // Ignore unreadable shims; caller will throw a guided error. - } - return null; -} - -function resolveWindowsLobsterSpawn(execPath: string, argv: string[], env: NodeJS.ProcessEnv) { - const resolvedExecPath = resolveWindowsExecutablePath(execPath, env); - const ext = path.extname(resolvedExecPath).toLowerCase(); - if (ext !== ".cmd" && ext !== ".bat") { - return { command: resolvedExecPath, argv }; - } - const scriptPath = - resolveLobsterScriptFromCmdShim(resolvedExecPath) ?? - resolveLobsterScriptFromPackageJson(resolvedExecPath); - if (!scriptPath) { - throw new Error( - `lobsterPath resolved to ${path.basename(resolvedExecPath)} wrapper, but no Node entrypoint could be resolved without shell execution. Configure pluginConfig.lobsterPath to lobster.exe.`, - ); - } - const entryExt = path.extname(scriptPath).toLowerCase(); - if (entryExt === ".exe") { - return { command: scriptPath, argv, windowsHide: true }; - } - return { command: process.execPath, argv: [scriptPath, ...argv], windowsHide: true }; -} - async function runLobsterSubprocessOnce(params: { execPath: string; argv: string[]; @@ -258,6 +117,30 @@ async function runLobsterSubprocessOnce(params: { let stdout = ""; let stdoutBytes = 0; let stderr = ""; + let settled = false; + + const settle = ( + result: { ok: true; value: { stdout: string } } | { ok: false; error: Error }, + ) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + if (result.ok) { + resolve(result.value); + } else { + reject(result.error); + } + }; + + const failAndTerminate = (message: string) => { + try { + child.kill("SIGKILL"); + } finally { + settle({ ok: false, error: new Error(message) }); + } + }; child.stdout?.setEncoding("utf8"); child.stderr?.setEncoding("utf8"); @@ -266,11 +149,7 @@ async function runLobsterSubprocessOnce(params: { const str = String(chunk); stdoutBytes += Buffer.byteLength(str, "utf8"); if (stdoutBytes > maxStdoutBytes) { - try { - child.kill("SIGKILL"); - } finally { - reject(new Error("lobster output exceeded maxStdoutBytes")); - } + failAndTerminate("lobster output exceeded maxStdoutBytes"); return; } stdout += str; @@ -281,25 +160,22 @@ async function runLobsterSubprocessOnce(params: { }); const timer = setTimeout(() => { - try { - child.kill("SIGKILL"); - } finally { - reject(new Error("lobster subprocess timed out")); - } + failAndTerminate("lobster subprocess timed out"); }, timeoutMs); child.once("error", (err) => { - clearTimeout(timer); - reject(err); + settle({ ok: false, error: err }); }); child.once("exit", (code) => { - clearTimeout(timer); if (code !== 0) { - reject(new Error(`lobster failed (${code ?? "?"}): ${stderr.trim() || stdout.trim()}`)); + settle({ + ok: false, + error: new Error(`lobster failed (${code ?? "?"}): ${stderr.trim() || stdout.trim()}`), + }); return; } - resolve({ stdout }); + settle({ ok: true, value: { stdout } }); }); }); } diff --git a/extensions/lobster/src/windows-spawn.ts b/extensions/lobster/src/windows-spawn.ts new file mode 100644 index 0000000000..f13d788490 --- /dev/null +++ b/extensions/lobster/src/windows-spawn.ts @@ -0,0 +1,176 @@ +import fs from "node:fs"; +import path from "node:path"; + +type SpawnTarget = { + command: string; + argv: string[]; + windowsHide?: boolean; +}; + +function isFilePath(value: string): boolean { + try { + const stat = fs.statSync(value); + return stat.isFile(); + } catch { + return false; + } +} + +function resolveWindowsExecutablePath(execPath: string, env: NodeJS.ProcessEnv): string { + if (execPath.includes("/") || execPath.includes("\\") || path.isAbsolute(execPath)) { + return execPath; + } + + const pathValue = env.PATH ?? env.Path ?? process.env.PATH ?? process.env.Path ?? ""; + const pathEntries = pathValue + .split(";") + .map((entry) => entry.trim()) + .filter(Boolean); + + const hasExtension = path.extname(execPath).length > 0; + const pathExtRaw = + env.PATHEXT ?? + env.Pathext ?? + process.env.PATHEXT ?? + process.env.Pathext ?? + ".EXE;.CMD;.BAT;.COM"; + const pathExt = hasExtension + ? [""] + : pathExtRaw + .split(";") + .map((ext) => ext.trim()) + .filter(Boolean) + .map((ext) => (ext.startsWith(".") ? ext : `.${ext}`)); + + for (const dir of pathEntries) { + for (const ext of pathExt) { + for (const candidateExt of [ext, ext.toLowerCase(), ext.toUpperCase()]) { + const candidate = path.join(dir, `${execPath}${candidateExt}`); + if (isFilePath(candidate)) { + return candidate; + } + } + } + } + + return execPath; +} + +function resolveBinEntry(binField: string | Record | undefined): string | null { + if (typeof binField === "string") { + const trimmed = binField.trim(); + return trimmed || null; + } + if (!binField || typeof binField !== "object") { + return null; + } + + const preferred = binField.lobster; + if (typeof preferred === "string" && preferred.trim()) { + return preferred.trim(); + } + + for (const value of Object.values(binField)) { + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + return null; +} + +function resolveLobsterScriptFromPackageJson(wrapperPath: string): string | null { + const wrapperDir = path.dirname(wrapperPath); + const packageDirs = [ + // Local install: /node_modules/.bin/lobster.cmd -> ../lobster + path.resolve(wrapperDir, "..", "lobster"), + // Global npm install: /lobster.cmd -> ./node_modules/lobster + path.resolve(wrapperDir, "node_modules", "lobster"), + ]; + + for (const packageDir of packageDirs) { + const packageJsonPath = path.join(packageDir, "package.json"); + if (!isFilePath(packageJsonPath)) { + continue; + } + + try { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { + bin?: string | Record; + }; + const scriptRel = resolveBinEntry(packageJson.bin); + if (!scriptRel) { + continue; + } + const scriptPath = path.resolve(packageDir, scriptRel); + if (isFilePath(scriptPath)) { + return scriptPath; + } + } catch { + // Ignore malformed package metadata; caller will throw a guided error. + } + } + + return null; +} + +function resolveLobsterScriptFromCmdShim(wrapperPath: string): string | null { + if (!isFilePath(wrapperPath)) { + return null; + } + + try { + const content = fs.readFileSync(wrapperPath, "utf8"); + const candidates: string[] = []; + const matches = content.matchAll(/"%~?dp0%\\([^"\r\n]+)"/gi); + for (const match of matches) { + const relative = match[1]; + if (!relative) { + continue; + } + const normalizedRelative = relative.replace(/[\\/]+/g, path.sep); + const candidate = path.resolve(path.dirname(wrapperPath), normalizedRelative); + if (isFilePath(candidate)) { + candidates.push(candidate); + } + } + + const nonNode = candidates.find((candidate) => { + const base = path.basename(candidate).toLowerCase(); + return base !== "node.exe" && base !== "node"; + }); + if (nonNode) { + return nonNode; + } + } catch { + // Ignore unreadable shims; caller will throw a guided error. + } + + return null; +} + +export function resolveWindowsLobsterSpawn( + execPath: string, + argv: string[], + env: NodeJS.ProcessEnv, +): SpawnTarget { + const resolvedExecPath = resolveWindowsExecutablePath(execPath, env); + const ext = path.extname(resolvedExecPath).toLowerCase(); + if (ext !== ".cmd" && ext !== ".bat") { + return { command: resolvedExecPath, argv }; + } + + const scriptPath = + resolveLobsterScriptFromCmdShim(resolvedExecPath) ?? + resolveLobsterScriptFromPackageJson(resolvedExecPath); + if (!scriptPath) { + throw new Error( + `lobsterPath resolved to ${path.basename(resolvedExecPath)} wrapper, but no Node entrypoint could be resolved without shell execution. Configure pluginConfig.lobsterPath to lobster.exe.`, + ); + } + + const entryExt = path.extname(scriptPath).toLowerCase(); + if (entryExt === ".exe") { + return { command: scriptPath, argv, windowsHide: true }; + } + return { command: process.execPath, argv: [scriptPath, ...argv], windowsHide: true }; +}