diff --git a/CHANGELOG.md b/CHANGELOG.md index b2987865409..3c33779804f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai - Security/Refactor: centralize hardened temp-file path generation for Feishu and LINE media downloads via shared `buildRandomTempFilePath` helper to reduce drift risk. (#20810) Thanks @mbelinky. - Security/Media: harden local media ingestion against TOCTOU/symlink swap attacks by pinning reads to a single file descriptor with symlink rejection and inode/device verification in `saveMediaSource`. Thanks @dorjoos for reporting. - Security/Lobster (Windows): for the next npm release, remove shell-based fallback when launching Lobster wrappers (`.cmd`/`.bat`) and switch to explicit argv execution with wrapper entrypoint resolution, preventing command injection while preserving Windows wrapper compatibility. Thanks @allsmog for reporting. +- Lobster/Config: remove Lobster executable-path overrides (`lobsterPath`), require PATH-based execution, and add focused Windows wrapper-resolution tests to keep shell-free behavior stable. - Agents/Streaming: keep assistant partial streaming active during reasoning streams, handle native `thinking_*` stream events consistently, dedupe mixed reasoning-end signals, and clear stale mutating tool errors after same-target retry success. (#20635) Thanks @obviyus. - Security/OTEL: sanitize OTLP endpoint URL resolution. (#13791) Thanks @vincentkoc. - OTEL/diagnostics-otel: complete OpenTelemetry v2 API migration. (#12897) Thanks @vincentkoc. diff --git a/docs/automation/cron-vs-heartbeat.md b/docs/automation/cron-vs-heartbeat.md index a138e721ae4..c25cbcb80db 100644 --- a/docs/automation/cron-vs-heartbeat.md +++ b/docs/automation/cron-vs-heartbeat.md @@ -211,7 +211,7 @@ For ad-hoc workflows, call Lobster directly. - Lobster runs as a **local subprocess** (`lobster` CLI) in tool mode and returns a **JSON envelope**. - If the tool returns `needs_approval`, you resume with a `resumeToken` and `approve` flag. - The tool is an **optional plugin**; enable it additively via `tools.alsoAllow: ["lobster"]` (recommended). -- If you pass `lobsterPath`, it must be an **absolute path**. +- Lobster expects the `lobster` CLI to be available on `PATH`. See [Lobster](/tools/lobster) for full usage and examples. diff --git a/docs/tools/lobster.md b/docs/tools/lobster.md index 31e4e17d521..65ff4f56dfb 100644 --- a/docs/tools/lobster.md +++ b/docs/tools/lobster.md @@ -154,7 +154,6 @@ Notes: ## Install Lobster Install the Lobster CLI on the **same host** that runs the OpenClaw Gateway (see the [Lobster repo](https://github.com/openclaw/lobster)), and ensure `lobster` is on `PATH`. -If you want to use a custom binary location, pass an **absolute** `lobsterPath` in the tool call. ## Enable the tool @@ -256,7 +255,7 @@ Run a pipeline in tool mode. { "action": "run", "pipeline": "gog.gmail.search --query 'newer_than:1d' | email.triage", - "cwd": "/path/to/workspace", + "cwd": "workspace", "timeoutMs": 30000, "maxStdoutBytes": 512000 } @@ -286,8 +285,7 @@ Continue a halted workflow after approval. ### Optional inputs -- `lobsterPath`: Absolute path to the Lobster binary (omit to use `PATH`). -- `cwd`: Working directory for the pipeline (defaults to the current process working directory). +- `cwd`: Relative working directory for the pipeline (must stay within the current process working directory). - `timeoutMs`: Kill the subprocess if it exceeds this duration (default: 20000). - `maxStdoutBytes`: Kill the subprocess if stdout exceeds this size (default: 512000). - `argsJson`: JSON string passed to `lobster run --args-json` (workflow files only). @@ -320,7 +318,7 @@ OpenProse pairs well with Lobster: use `/prose` to orchestrate multi-agent prep, - **Local subprocess only** — no network calls from the plugin itself. - **No secrets** — Lobster doesn't manage OAuth; it calls OpenClaw tools that do. - **Sandbox-aware** — disabled when the tool context is sandboxed. -- **Hardened** — `lobsterPath` must be absolute if specified; timeouts and output caps enforced. +- **Hardened** — fixed executable name (`lobster`) on `PATH`; timeouts and output caps enforced. ## Troubleshooting diff --git a/extensions/lobster/README.md b/extensions/lobster/README.md index 8a7d600f1c0..03c083e6227 100644 --- a/extensions/lobster/README.md +++ b/extensions/lobster/README.md @@ -72,4 +72,4 @@ Notes: - Runs the `lobster` executable as a local subprocess. - Does not manage OAuth/tokens. - Uses timeouts, stdout caps, and strict JSON envelope parsing. -- Prefer an absolute `lobsterPath` in production to avoid PATH hijack. +- Ensure `lobster` is available on `PATH` for the gateway process. diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 50a7dbf9358..294e625ce2b 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -66,8 +66,6 @@ function setProcessPlatform(platform: NodeJS.Platform) { describe("lobster plugin tool", () => { let tempDir = ""; - let lobsterBinPath = ""; - let lobsterExePath = ""; const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); const originalPath = process.env.PATH; const originalPathAlt = process.env.Path; @@ -78,10 +76,6 @@ describe("lobster plugin tool", () => { ({ createLobsterTool } = await import("./lobster-tool.js")); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-plugin-")); - lobsterBinPath = path.join(tempDir, process.platform === "win32" ? "lobster.cmd" : "lobster"); - lobsterExePath = path.join(tempDir, "lobster.exe"); - await fs.writeFile(lobsterBinPath, "", { encoding: "utf8", mode: 0o755 }); - await fs.writeFile(lobsterExePath, "", { encoding: "utf8", mode: 0o755 }); }); afterEach(() => { @@ -151,6 +145,28 @@ describe("lobster plugin tool", () => { }); }); + const queueSuccessfulEnvelope = (hello = "world") => { + spawnState.queue.push({ + stdout: JSON.stringify({ + ok: true, + status: "ok", + output: [{ hello }], + requiresApproval: null, + }), + }); + }; + + const createWindowsShimFixture = async (params: { + shimPath: string; + scriptPath: string; + scriptToken: string; + }) => { + await fs.mkdir(path.dirname(params.scriptPath), { recursive: true }); + await fs.mkdir(path.dirname(params.shimPath), { recursive: true }); + await fs.writeFile(params.scriptPath, "module.exports = {};\n", "utf8"); + await fs.writeFile(params.shimPath, `@echo off\r\n"${params.scriptToken}" %*\r\n`, "utf8"); + }; + it("runs lobster and returns parsed envelope in details", async () => { spawnState.queue.push({ stdout: JSON.stringify({ @@ -188,26 +204,43 @@ describe("lobster plugin tool", () => { expect(res.details).toMatchObject({ ok: true, status: "ok" }); }); - it("requires absolute lobsterPath when provided (even though it is ignored)", async () => { + it("requires action", async () => { const tool = createLobsterTool(fakeApi()); - await expect( - tool.execute("call2", { - action: "run", - pipeline: "noop", - lobsterPath: "./lobster", - }), - ).rejects.toThrow(/absolute path/); + await expect(tool.execute("call-action-missing", {})).rejects.toThrow(/action required/); }); - it("rejects lobsterPath (deprecated) when invalid", async () => { + it("requires pipeline for run action", async () => { const tool = createLobsterTool(fakeApi()); await expect( - tool.execute("call2b", { + tool.execute("call-pipeline-missing", { action: "run", - pipeline: "noop", - lobsterPath: "/bin/bash", }), - ).rejects.toThrow(/lobster executable/); + ).rejects.toThrow(/pipeline required/); + }); + + it("requires token and approve for resume action", async () => { + const tool = createLobsterTool(fakeApi()); + await expect( + tool.execute("call-resume-token-missing", { + action: "resume", + approve: true, + }), + ).rejects.toThrow(/token required/); + await expect( + tool.execute("call-resume-approve-missing", { + action: "resume", + token: "resume-token", + }), + ).rejects.toThrow(/approve required/); + }); + + it("rejects unknown action", async () => { + const tool = createLobsterTool(fakeApi()); + await expect( + tool.execute("call-action-unknown", { + action: "explode", + }), + ).rejects.toThrow(/Unknown action/); }); it("rejects absolute cwd", async () => { @@ -232,32 +265,6 @@ describe("lobster plugin tool", () => { ).rejects.toThrow(/must stay within/); }); - it("uses pluginConfig.lobsterPath when provided", async () => { - spawnState.queue.push({ - stdout: JSON.stringify({ - ok: true, - status: "ok", - output: [{ hello: "world" }], - requiresApproval: null, - }), - }); - - const configuredLobsterPath = process.platform === "win32" ? lobsterExePath : lobsterBinPath; - const tool = createLobsterTool( - fakeApi({ pluginConfig: { lobsterPath: configuredLobsterPath } }), - ); - const res = await tool.execute("call-plugin-config", { - action: "run", - pipeline: "noop", - timeoutMs: 1000, - }); - - expect(spawnState.spawn).toHaveBeenCalled(); - const [execPath] = spawnState.spawn.mock.calls[0] ?? []; - expect(execPath).toBe(configuredLobsterPath); - expect(res.details).toMatchObject({ ok: true, status: "ok" }); - }); - it("rejects invalid JSON from lobster", async () => { spawnState.queue.push({ stdout: "nope" }); @@ -273,25 +280,17 @@ describe("lobster plugin tool", () => { it("runs Windows cmd shims through Node without enabling shell", async () => { setProcessPlatform("win32"); const shimScriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); - const shimPath = path.join(tempDir, "shim", "lobster.cmd"); - await fs.mkdir(path.dirname(shimScriptPath), { recursive: true }); - await fs.mkdir(path.dirname(shimPath), { recursive: true }); - await fs.writeFile(shimScriptPath, "module.exports = {};\n", "utf8"); - await fs.writeFile( + const shimPath = path.join(tempDir, "shim-bin", "lobster.cmd"); + await createWindowsShimFixture({ shimPath, - `@echo off\r\n"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`, - "utf8", - ); - spawnState.queue.push({ - stdout: JSON.stringify({ - ok: true, - status: "ok", - output: [{ hello: "world" }], - requiresApproval: null, - }), + scriptPath: shimScriptPath, + scriptToken: "%dp0%\\..\\shim-dist\\lobster-cli.cjs", }); + process.env.PATHEXT = ".CMD;.EXE"; + process.env.PATH = `${path.dirname(shimPath)};${process.env.PATH ?? ""}`; + queueSuccessfulEnvelope(); - const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: shimPath } })); + const tool = createLobsterTool(fakeApi()); await tool.execute("call-win-shim", { action: "run", pipeline: "noop", @@ -304,127 +303,6 @@ describe("lobster plugin tool", () => { expect(options).not.toHaveProperty("shell"); }); - it("runs Windows cmd shims with rooted dp0 tokens through Node", async () => { - setProcessPlatform("win32"); - const shimScriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); - const shimPath = path.join(tempDir, "shim", "lobster.cmd"); - await fs.mkdir(path.dirname(shimScriptPath), { recursive: true }); - await fs.mkdir(path.dirname(shimPath), { recursive: true }); - await fs.writeFile(shimScriptPath, "module.exports = {};\n", "utf8"); - await fs.writeFile( - shimPath, - `@echo off\r\n"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`, - "utf8", - ); - spawnState.queue.push({ - stdout: JSON.stringify({ - ok: true, - status: "ok", - output: [{ hello: "rooted" }], - requiresApproval: null, - }), - }); - - const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: shimPath } })); - await tool.execute("call-win-rooted-shim", { - action: "run", - pipeline: "noop", - }); - - const [command, argv] = spawnState.spawn.mock.calls[0] ?? []; - expect(command).toBe(process.execPath); - expect(argv).toEqual([shimScriptPath, "run", "--mode", "tool", "noop"]); - }); - - it("ignores node.exe shim entries and resolves the actual lobster script", async () => { - setProcessPlatform("win32"); - const shimDir = path.join(tempDir, "shim-with-node"); - const nodeExePath = path.join(shimDir, "node.exe"); - const scriptPath = path.join(tempDir, "shim-dist-node", "lobster-cli.cjs"); - const shimPath = path.join(shimDir, "lobster.cmd"); - await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - await fs.mkdir(shimDir, { recursive: true }); - await fs.writeFile(nodeExePath, "", "utf8"); - await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); - await fs.writeFile( - shimPath, - `@echo off\r\n"%~dp0%\\node.exe" "%~dp0%\\..\\shim-dist-node\\lobster-cli.cjs" %*\r\n`, - "utf8", - ); - spawnState.queue.push({ - stdout: JSON.stringify({ - ok: true, - status: "ok", - output: [{ hello: "node-first" }], - requiresApproval: null, - }), - }); - - const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: shimPath } })); - await tool.execute("call-win-node-first", { - action: "run", - pipeline: "noop", - }); - - const [command, argv] = spawnState.spawn.mock.calls[0] ?? []; - expect(command).toBe(process.execPath); - expect(argv).toEqual([scriptPath, "run", "--mode", "tool", "noop"]); - }); - - it("resolves lobster.cmd from PATH and unwraps npm layout shim", async () => { - setProcessPlatform("win32"); - const binDir = path.join(tempDir, "node_modules", ".bin"); - const packageDir = path.join(tempDir, "node_modules", "lobster"); - const scriptPath = path.join(packageDir, "dist", "cli.js"); - const shimPath = path.join(binDir, "lobster.cmd"); - await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - await fs.mkdir(binDir, { recursive: true }); - await fs.writeFile(shimPath, "@echo off\r\n", "utf8"); - await fs.writeFile( - path.join(packageDir, "package.json"), - JSON.stringify({ name: "lobster", version: "0.0.0", bin: { lobster: "dist/cli.js" } }), - "utf8", - ); - await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); - process.env.PATHEXT = ".CMD;.EXE"; - process.env.PATH = `${binDir};${process.env.PATH ?? ""}`; - - spawnState.queue.push({ - stdout: JSON.stringify({ - ok: true, - status: "ok", - output: [{ hello: "path" }], - requiresApproval: null, - }), - }); - - const tool = createLobsterTool(fakeApi()); - await tool.execute("call-win-path", { - action: "run", - pipeline: "noop", - }); - - const [command, argv] = spawnState.spawn.mock.calls[0] ?? []; - expect(command).toBe(process.execPath); - expect(argv).toEqual([scriptPath, "run", "--mode", "tool", "noop"]); - }); - - it("fails fast when cmd wrapper cannot be resolved without shell execution", async () => { - setProcessPlatform("win32"); - const badShimPath = path.join(tempDir, "bad-shim", "lobster.cmd"); - await fs.mkdir(path.dirname(badShimPath), { recursive: true }); - await fs.writeFile(badShimPath, "@echo off\r\nREM no entrypoint\r\n", "utf8"); - - const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: badShimPath } })); - await expect( - tool.execute("call-win-bad", { - action: "run", - pipeline: "noop", - }), - ).rejects.toThrow(/without shell execution/); - expect(spawnState.spawn).not.toHaveBeenCalled(); - }); - it("does not retry a failed Windows spawn with shell fallback", async () => { setProcessPlatform("win32"); spawnState.spawn.mockReset(); @@ -442,7 +320,7 @@ describe("lobster plugin tool", () => { return child; }); - const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: lobsterExePath } })); + const tool = createLobsterTool(fakeApi()); await expect( tool.execute("call-win-no-retry", { action: "run", diff --git a/extensions/lobster/src/lobster-tool.ts b/extensions/lobster/src/lobster-tool.ts index 5c46261938f..e4402861ef5 100644 --- a/extensions/lobster/src/lobster-tool.ts +++ b/extensions/lobster/src/lobster-tool.ts @@ -1,5 +1,4 @@ import { spawn } from "node:child_process"; -import fs from "node:fs"; import path from "node:path"; import { Type } from "@sinclair/typebox"; import type { OpenClawPluginApi } from "../../../src/plugins/types.js"; @@ -22,43 +21,6 @@ type LobsterEnvelope = error: { type?: string; message: string }; }; -function resolveExecutablePath(lobsterPathRaw: string | undefined) { - const lobsterPath = lobsterPathRaw?.trim() || "lobster"; - - // SECURITY: - // Never allow arbitrary executables (e.g. /bin/bash). If the caller overrides - // the path, it must still be the lobster binary (by name) and be absolute. - if (lobsterPath !== "lobster") { - if (!path.isAbsolute(lobsterPath)) { - throw new Error("lobsterPath must be an absolute path (or omit to use PATH)"); - } - const base = path.basename(lobsterPath).toLowerCase(); - const allowed = - process.platform === "win32" ? ["lobster.exe", "lobster.cmd", "lobster.bat"] : ["lobster"]; - if (!allowed.includes(base)) { - throw new Error("lobsterPath must point to the lobster executable"); - } - let stat: fs.Stats; - try { - stat = fs.statSync(lobsterPath); - } catch { - throw new Error("lobsterPath must exist"); - } - if (!stat.isFile()) { - throw new Error("lobsterPath must point to a file"); - } - if (process.platform !== "win32") { - try { - fs.accessSync(lobsterPath, fs.constants.X_OK); - } catch { - throw new Error("lobsterPath must be executable"); - } - } - } - - return lobsterPath; -} - function normalizeForCwdSandbox(p: string): string { const normalized = path.normalize(p); return process.platform === "win32" ? normalized.toLowerCase() : normalized; @@ -180,16 +142,6 @@ async function runLobsterSubprocessOnce(params: { }); } -async function runLobsterSubprocess(params: { - execPath: string; - argv: string[]; - cwd: string; - timeoutMs: number; - maxStdoutBytes: number; -}) { - return await runLobsterSubprocessOnce(params); -} - function parseEnvelope(stdout: string): LobsterEnvelope { const trimmed = stdout.trim(); @@ -228,6 +180,33 @@ function parseEnvelope(stdout: string): LobsterEnvelope { throw new Error("lobster returned invalid JSON envelope"); } +function buildLobsterArgv(action: string, params: Record): string[] { + if (action === "run") { + const pipeline = typeof params.pipeline === "string" ? params.pipeline : ""; + if (!pipeline.trim()) { + throw new Error("pipeline required"); + } + const argv = ["run", "--mode", "tool", pipeline]; + const argsJson = typeof params.argsJson === "string" ? params.argsJson : ""; + if (argsJson.trim()) { + argv.push("--args-json", argsJson); + } + return argv; + } + if (action === "resume") { + const token = typeof params.token === "string" ? params.token : ""; + if (!token.trim()) { + throw new Error("token required"); + } + const approve = params.approve; + if (typeof approve !== "boolean") { + throw new Error("approve required"); + } + return ["resume", "--token", token, "--approve", approve ? "yes" : "no"]; + } + throw new Error(`Unknown action: ${action}`); +} + export function createLobsterTool(api: OpenClawPluginApi) { return { name: "lobster", @@ -241,11 +220,6 @@ export function createLobsterTool(api: OpenClawPluginApi) { argsJson: Type.Optional(Type.String()), token: Type.Optional(Type.String()), approve: Type.Optional(Type.Boolean()), - // SECURITY: Do not allow the agent to choose an executable path. - // Host can configure the lobster binary via plugin config. - lobsterPath: Type.Optional( - Type.String({ description: "(deprecated) Use plugin config instead." }), - ), cwd: Type.Optional( Type.String({ description: @@ -261,55 +235,19 @@ export function createLobsterTool(api: OpenClawPluginApi) { throw new Error("action required"); } - // SECURITY: never allow tool callers (agent/user) to select executables. - // If a host needs to override the binary, it must do so via plugin config. - // We still validate the parameter shape to prevent reintroducing an RCE footgun. - if (typeof params.lobsterPath === "string" && params.lobsterPath.trim()) { - resolveExecutablePath(params.lobsterPath); - } - - const execPath = resolveExecutablePath( - typeof api.pluginConfig?.lobsterPath === "string" - ? api.pluginConfig.lobsterPath - : undefined, - ); + const execPath = "lobster"; const cwd = resolveCwd(params.cwd); const timeoutMs = typeof params.timeoutMs === "number" ? params.timeoutMs : 20_000; const maxStdoutBytes = typeof params.maxStdoutBytes === "number" ? params.maxStdoutBytes : 512_000; - const argv = (() => { - if (action === "run") { - const pipeline = typeof params.pipeline === "string" ? params.pipeline : ""; - if (!pipeline.trim()) { - throw new Error("pipeline required"); - } - const argv = ["run", "--mode", "tool", pipeline]; - const argsJson = typeof params.argsJson === "string" ? params.argsJson : ""; - if (argsJson.trim()) { - argv.push("--args-json", argsJson); - } - return argv; - } - if (action === "resume") { - const token = typeof params.token === "string" ? params.token : ""; - if (!token.trim()) { - throw new Error("token required"); - } - const approve = params.approve; - if (typeof approve !== "boolean") { - throw new Error("approve required"); - } - return ["resume", "--token", token, "--approve", approve ? "yes" : "no"]; - } - throw new Error(`Unknown action: ${action}`); - })(); + const argv = buildLobsterArgv(action, params); if (api.runtime?.version && api.logger?.debug) { api.logger.debug(`lobster plugin runtime=${api.runtime.version}`); } - const { stdout } = await runLobsterSubprocess({ + const { stdout } = await runLobsterSubprocessOnce({ execPath, argv, cwd, diff --git a/extensions/lobster/src/windows-spawn.test.ts b/extensions/lobster/src/windows-spawn.test.ts new file mode 100644 index 00000000000..75f49f34b05 --- /dev/null +++ b/extensions/lobster/src/windows-spawn.test.ts @@ -0,0 +1,148 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resolveWindowsLobsterSpawn } from "./windows-spawn.js"; + +function setProcessPlatform(platform: NodeJS.Platform) { + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); +} + +describe("resolveWindowsLobsterSpawn", () => { + let tempDir = ""; + const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + const originalPath = process.env.PATH; + const originalPathAlt = process.env.Path; + const originalPathExt = process.env.PATHEXT; + const originalPathExtAlt = process.env.Pathext; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-win-spawn-")); + setProcessPlatform("win32"); + }); + + afterEach(async () => { + if (originalPlatform) { + Object.defineProperty(process, "platform", originalPlatform); + } + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + if (originalPathAlt === undefined) { + delete process.env.Path; + } else { + process.env.Path = originalPathAlt; + } + if (originalPathExt === undefined) { + delete process.env.PATHEXT; + } else { + process.env.PATHEXT = originalPathExt; + } + if (originalPathExtAlt === undefined) { + delete process.env.Pathext; + } else { + process.env.Pathext = originalPathExtAlt; + } + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + tempDir = ""; + } + }); + + it("unwraps cmd shim with %dp0% token", async () => { + const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); + const shimPath = path.join(tempDir, "shim", "lobster.cmd"); + await fs.mkdir(path.dirname(scriptPath), { recursive: true }); + await fs.mkdir(path.dirname(shimPath), { recursive: true }); + await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); + await fs.writeFile( + shimPath, + `@echo off\r\n"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`, + "utf8", + ); + + const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env); + expect(target.command).toBe(process.execPath); + expect(target.argv).toEqual([scriptPath, "run", "noop"]); + expect(target.windowsHide).toBe(true); + }); + + it("unwraps cmd shim with %~dp0% token", async () => { + const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); + const shimPath = path.join(tempDir, "shim", "lobster.cmd"); + await fs.mkdir(path.dirname(scriptPath), { recursive: true }); + await fs.mkdir(path.dirname(shimPath), { recursive: true }); + await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); + await fs.writeFile( + shimPath, + `@echo off\r\n"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`, + "utf8", + ); + + const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env); + expect(target.command).toBe(process.execPath); + expect(target.argv).toEqual([scriptPath, "run", "noop"]); + expect(target.windowsHide).toBe(true); + }); + + it("ignores node.exe shim entries and picks lobster script", async () => { + const shimDir = path.join(tempDir, "shim-with-node"); + const scriptPath = path.join(tempDir, "shim-dist-node", "lobster-cli.cjs"); + const shimPath = path.join(shimDir, "lobster.cmd"); + await fs.mkdir(path.dirname(scriptPath), { recursive: true }); + await fs.mkdir(shimDir, { recursive: true }); + await fs.writeFile(path.join(shimDir, "node.exe"), "", "utf8"); + await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); + await fs.writeFile( + shimPath, + `@echo off\r\n"%~dp0%\\node.exe" "%~dp0%\\..\\shim-dist-node\\lobster-cli.cjs" %*\r\n`, + "utf8", + ); + + const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env); + expect(target.command).toBe(process.execPath); + expect(target.argv).toEqual([scriptPath, "run", "noop"]); + expect(target.windowsHide).toBe(true); + }); + + it("resolves lobster.cmd from PATH and unwraps npm layout shim", async () => { + const binDir = path.join(tempDir, "node_modules", ".bin"); + const packageDir = path.join(tempDir, "node_modules", "lobster"); + const scriptPath = path.join(packageDir, "dist", "cli.js"); + const shimPath = path.join(binDir, "lobster.cmd"); + await fs.mkdir(path.dirname(scriptPath), { recursive: true }); + await fs.mkdir(binDir, { recursive: true }); + await fs.writeFile(shimPath, "@echo off\r\n", "utf8"); + await fs.writeFile( + path.join(packageDir, "package.json"), + JSON.stringify({ name: "lobster", version: "0.0.0", bin: { lobster: "dist/cli.js" } }), + "utf8", + ); + await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); + + const env = { + ...process.env, + PATH: `${binDir};${process.env.PATH ?? ""}`, + PATHEXT: ".CMD;.EXE", + }; + const target = resolveWindowsLobsterSpawn("lobster", ["run", "noop"], env); + expect(target.command).toBe(process.execPath); + expect(target.argv).toEqual([scriptPath, "run", "noop"]); + expect(target.windowsHide).toBe(true); + }); + + it("fails fast when wrapper cannot be resolved without shell execution", async () => { + const badShimPath = path.join(tempDir, "bad-shim", "lobster.cmd"); + await fs.mkdir(path.dirname(badShimPath), { recursive: true }); + await fs.writeFile(badShimPath, "@echo off\r\nREM no entrypoint\r\n", "utf8"); + + expect(() => resolveWindowsLobsterSpawn(badShimPath, ["run", "noop"], process.env)).toThrow( + /without shell execution/, + ); + }); +}); diff --git a/extensions/lobster/src/windows-spawn.ts b/extensions/lobster/src/windows-spawn.ts index a5c4c2bc9ff..a416b759c93 100644 --- a/extensions/lobster/src/windows-spawn.ts +++ b/extensions/lobster/src/windows-spawn.ts @@ -181,7 +181,7 @@ export function resolveWindowsLobsterSpawn( 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.`, + `${path.basename(resolvedExecPath)} wrapper resolved, but no Node entrypoint could be resolved without shell execution. Ensure Lobster is installed and runnable on PATH (prefer lobster.exe).`, ); }