@openclaw/lobster 2026.2.17 → 2026.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -72,4 +72,4 @@ Notes:
72
72
  - Runs the `lobster` executable as a local subprocess.
73
73
  - Does not manage OAuth/tokens.
74
74
  - Uses timeouts, stdout caps, and strict JSON envelope parsing.
75
- - Prefer an absolute `lobsterPath` in production to avoid PATH hijack.
75
+ - Ensure `lobster` is available on `PATH` for the gateway process.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/lobster",
3
- "version": "2026.2.17",
3
+ "version": "2026.2.19",
4
4
  "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
5
5
  "type": "module",
6
6
  "devDependencies": {
@@ -3,7 +3,7 @@ import fs from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { PassThrough } from "node:stream";
6
- import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
7
7
  import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../../../src/plugins/types.js";
8
8
 
9
9
  const spawnState = vi.hoisted(() => ({
@@ -57,16 +57,51 @@ function fakeCtx(overrides: Partial<OpenClawPluginToolContext> = {}): OpenClawPl
57
57
  };
58
58
  }
59
59
 
60
+ function setProcessPlatform(platform: NodeJS.Platform) {
61
+ Object.defineProperty(process, "platform", {
62
+ value: platform,
63
+ configurable: true,
64
+ });
65
+ }
66
+
60
67
  describe("lobster plugin tool", () => {
61
68
  let tempDir = "";
62
- let lobsterBinPath = "";
69
+ const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
70
+ const originalPath = process.env.PATH;
71
+ const originalPathAlt = process.env.Path;
72
+ const originalPathExt = process.env.PATHEXT;
73
+ const originalPathExtAlt = process.env.Pathext;
63
74
 
64
75
  beforeAll(async () => {
65
76
  ({ createLobsterTool } = await import("./lobster-tool.js"));
66
77
 
67
78
  tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-plugin-"));
68
- lobsterBinPath = path.join(tempDir, process.platform === "win32" ? "lobster.cmd" : "lobster");
69
- await fs.writeFile(lobsterBinPath, "", { encoding: "utf8", mode: 0o755 });
79
+ });
80
+
81
+ afterEach(() => {
82
+ if (originalPlatform) {
83
+ Object.defineProperty(process, "platform", originalPlatform);
84
+ }
85
+ if (originalPath === undefined) {
86
+ delete process.env.PATH;
87
+ } else {
88
+ process.env.PATH = originalPath;
89
+ }
90
+ if (originalPathAlt === undefined) {
91
+ delete process.env.Path;
92
+ } else {
93
+ process.env.Path = originalPathAlt;
94
+ }
95
+ if (originalPathExt === undefined) {
96
+ delete process.env.PATHEXT;
97
+ } else {
98
+ process.env.PATHEXT = originalPathExt;
99
+ }
100
+ if (originalPathExtAlt === undefined) {
101
+ delete process.env.Pathext;
102
+ } else {
103
+ process.env.Pathext = originalPathExtAlt;
104
+ }
70
105
  });
71
106
 
72
107
  afterAll(async () => {
@@ -110,6 +145,28 @@ describe("lobster plugin tool", () => {
110
145
  });
111
146
  });
112
147
 
148
+ const queueSuccessfulEnvelope = (hello = "world") => {
149
+ spawnState.queue.push({
150
+ stdout: JSON.stringify({
151
+ ok: true,
152
+ status: "ok",
153
+ output: [{ hello }],
154
+ requiresApproval: null,
155
+ }),
156
+ });
157
+ };
158
+
159
+ const createWindowsShimFixture = async (params: {
160
+ shimPath: string;
161
+ scriptPath: string;
162
+ scriptToken: string;
163
+ }) => {
164
+ await fs.mkdir(path.dirname(params.scriptPath), { recursive: true });
165
+ await fs.mkdir(path.dirname(params.shimPath), { recursive: true });
166
+ await fs.writeFile(params.scriptPath, "module.exports = {};\n", "utf8");
167
+ await fs.writeFile(params.shimPath, `@echo off\r\n"${params.scriptToken}" %*\r\n`, "utf8");
168
+ };
169
+
113
170
  it("runs lobster and returns parsed envelope in details", async () => {
114
171
  spawnState.queue.push({
115
172
  stdout: JSON.stringify({
@@ -147,26 +204,43 @@ describe("lobster plugin tool", () => {
147
204
  expect(res.details).toMatchObject({ ok: true, status: "ok" });
148
205
  });
149
206
 
150
- it("requires absolute lobsterPath when provided (even though it is ignored)", async () => {
207
+ it("requires action", async () => {
208
+ const tool = createLobsterTool(fakeApi());
209
+ await expect(tool.execute("call-action-missing", {})).rejects.toThrow(/action required/);
210
+ });
211
+
212
+ it("requires pipeline for run action", async () => {
151
213
  const tool = createLobsterTool(fakeApi());
152
214
  await expect(
153
- tool.execute("call2", {
215
+ tool.execute("call-pipeline-missing", {
154
216
  action: "run",
155
- pipeline: "noop",
156
- lobsterPath: "./lobster",
157
217
  }),
158
- ).rejects.toThrow(/absolute path/);
218
+ ).rejects.toThrow(/pipeline required/);
159
219
  });
160
220
 
161
- it("rejects lobsterPath (deprecated) when invalid", async () => {
221
+ it("requires token and approve for resume action", async () => {
162
222
  const tool = createLobsterTool(fakeApi());
163
223
  await expect(
164
- tool.execute("call2b", {
165
- action: "run",
166
- pipeline: "noop",
167
- lobsterPath: "/bin/bash",
224
+ tool.execute("call-resume-token-missing", {
225
+ action: "resume",
226
+ approve: true,
227
+ }),
228
+ ).rejects.toThrow(/token required/);
229
+ await expect(
230
+ tool.execute("call-resume-approve-missing", {
231
+ action: "resume",
232
+ token: "resume-token",
233
+ }),
234
+ ).rejects.toThrow(/approve required/);
235
+ });
236
+
237
+ it("rejects unknown action", async () => {
238
+ const tool = createLobsterTool(fakeApi());
239
+ await expect(
240
+ tool.execute("call-action-unknown", {
241
+ action: "explode",
168
242
  }),
169
- ).rejects.toThrow(/lobster executable/);
243
+ ).rejects.toThrow(/Unknown action/);
170
244
  });
171
245
 
172
246
  it("rejects absolute cwd", async () => {
@@ -191,39 +265,69 @@ describe("lobster plugin tool", () => {
191
265
  ).rejects.toThrow(/must stay within/);
192
266
  });
193
267
 
194
- it("uses pluginConfig.lobsterPath when provided", async () => {
195
- spawnState.queue.push({
196
- stdout: JSON.stringify({
197
- ok: true,
198
- status: "ok",
199
- output: [{ hello: "world" }],
200
- requiresApproval: null,
268
+ it("rejects invalid JSON from lobster", async () => {
269
+ spawnState.queue.push({ stdout: "nope" });
270
+
271
+ const tool = createLobsterTool(fakeApi());
272
+ await expect(
273
+ tool.execute("call3", {
274
+ action: "run",
275
+ pipeline: "noop",
201
276
  }),
277
+ ).rejects.toThrow(/invalid JSON/);
278
+ });
279
+
280
+ it("runs Windows cmd shims through Node without enabling shell", async () => {
281
+ setProcessPlatform("win32");
282
+ const shimScriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
283
+ const shimPath = path.join(tempDir, "shim-bin", "lobster.cmd");
284
+ await createWindowsShimFixture({
285
+ shimPath,
286
+ scriptPath: shimScriptPath,
287
+ scriptToken: "%dp0%\\..\\shim-dist\\lobster-cli.cjs",
202
288
  });
289
+ process.env.PATHEXT = ".CMD;.EXE";
290
+ process.env.PATH = `${path.dirname(shimPath)};${process.env.PATH ?? ""}`;
291
+ queueSuccessfulEnvelope();
203
292
 
204
- const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: lobsterBinPath } }));
205
- const res = await tool.execute("call-plugin-config", {
293
+ const tool = createLobsterTool(fakeApi());
294
+ await tool.execute("call-win-shim", {
206
295
  action: "run",
207
296
  pipeline: "noop",
208
- timeoutMs: 1000,
209
297
  });
210
298
 
211
- expect(spawnState.spawn).toHaveBeenCalled();
212
- const [execPath] = spawnState.spawn.mock.calls[0] ?? [];
213
- expect(execPath).toBe(lobsterBinPath);
214
- expect(res.details).toMatchObject({ ok: true, status: "ok" });
299
+ const [command, argv, options] = spawnState.spawn.mock.calls[0] ?? [];
300
+ expect(command).toBe(process.execPath);
301
+ expect(argv).toEqual([shimScriptPath, "run", "--mode", "tool", "noop"]);
302
+ expect(options).toMatchObject({ windowsHide: true });
303
+ expect(options).not.toHaveProperty("shell");
215
304
  });
216
305
 
217
- it("rejects invalid JSON from lobster", async () => {
218
- spawnState.queue.push({ stdout: "nope" });
306
+ it("does not retry a failed Windows spawn with shell fallback", async () => {
307
+ setProcessPlatform("win32");
308
+ spawnState.spawn.mockReset();
309
+ spawnState.spawn.mockImplementationOnce(() => {
310
+ const child = new EventEmitter() as EventEmitter & {
311
+ stdout: PassThrough;
312
+ stderr: PassThrough;
313
+ kill: (signal?: string) => boolean;
314
+ };
315
+ child.stdout = new PassThrough();
316
+ child.stderr = new PassThrough();
317
+ child.kill = () => true;
318
+ const err = Object.assign(new Error("spawn failed"), { code: "ENOENT" });
319
+ setImmediate(() => child.emit("error", err));
320
+ return child;
321
+ });
219
322
 
220
323
  const tool = createLobsterTool(fakeApi());
221
324
  await expect(
222
- tool.execute("call3", {
325
+ tool.execute("call-win-no-retry", {
223
326
  action: "run",
224
327
  pipeline: "noop",
225
328
  }),
226
- ).rejects.toThrow(/invalid JSON/);
329
+ ).rejects.toThrow(/spawn failed/);
330
+ expect(spawnState.spawn).toHaveBeenCalledTimes(1);
227
331
  });
228
332
 
229
333
  it("can be gated off in sandboxed contexts", async () => {
@@ -1,8 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
- import fs from "node:fs";
3
2
  import path from "node:path";
4
3
  import { Type } from "@sinclair/typebox";
5
4
  import type { OpenClawPluginApi } from "../../../src/plugins/types.js";
5
+ import { resolveWindowsLobsterSpawn } from "./windows-spawn.js";
6
6
 
7
7
  type LobsterEnvelope =
8
8
  | {
@@ -21,43 +21,6 @@ type LobsterEnvelope =
21
21
  error: { type?: string; message: string };
22
22
  };
23
23
 
24
- function resolveExecutablePath(lobsterPathRaw: string | undefined) {
25
- const lobsterPath = lobsterPathRaw?.trim() || "lobster";
26
-
27
- // SECURITY:
28
- // Never allow arbitrary executables (e.g. /bin/bash). If the caller overrides
29
- // the path, it must still be the lobster binary (by name) and be absolute.
30
- if (lobsterPath !== "lobster") {
31
- if (!path.isAbsolute(lobsterPath)) {
32
- throw new Error("lobsterPath must be an absolute path (or omit to use PATH)");
33
- }
34
- const base = path.basename(lobsterPath).toLowerCase();
35
- const allowed =
36
- process.platform === "win32" ? ["lobster.exe", "lobster.cmd", "lobster.bat"] : ["lobster"];
37
- if (!allowed.includes(base)) {
38
- throw new Error("lobsterPath must point to the lobster executable");
39
- }
40
- let stat: fs.Stats;
41
- try {
42
- stat = fs.statSync(lobsterPath);
43
- } catch {
44
- throw new Error("lobsterPath must exist");
45
- }
46
- if (!stat.isFile()) {
47
- throw new Error("lobsterPath must point to a file");
48
- }
49
- if (process.platform !== "win32") {
50
- try {
51
- fs.accessSync(lobsterPath, fs.constants.X_OK);
52
- } catch {
53
- throw new Error("lobsterPath must be executable");
54
- }
55
- }
56
- }
57
-
58
- return lobsterPath;
59
- }
60
-
61
24
  function normalizeForCwdSandbox(p: string): string {
62
25
  const normalized = path.normalize(p);
63
26
  return process.platform === "win32" ? normalized.toLowerCase() : normalized;
@@ -84,28 +47,13 @@ function resolveCwd(cwdRaw: unknown): string {
84
47
  return resolved;
85
48
  }
86
49
 
87
- function isWindowsSpawnErrorThatCanUseShell(err: unknown) {
88
- if (!err || typeof err !== "object") {
89
- return false;
90
- }
91
- const code = (err as { code?: unknown }).code;
92
-
93
- // On Windows, spawning scripts discovered on PATH (e.g. lobster.cmd) can fail
94
- // with EINVAL, and PATH discovery itself can fail with ENOENT when the binary
95
- // is only available via PATHEXT/script wrappers.
96
- return code === "EINVAL" || code === "ENOENT";
97
- }
98
-
99
- async function runLobsterSubprocessOnce(
100
- params: {
101
- execPath: string;
102
- argv: string[];
103
- cwd: string;
104
- timeoutMs: number;
105
- maxStdoutBytes: number;
106
- },
107
- useShell: boolean,
108
- ) {
50
+ async function runLobsterSubprocessOnce(params: {
51
+ execPath: string;
52
+ argv: string[];
53
+ cwd: string;
54
+ timeoutMs: number;
55
+ maxStdoutBytes: number;
56
+ }) {
109
57
  const { execPath, argv, cwd } = params;
110
58
  const timeoutMs = Math.max(200, params.timeoutMs);
111
59
  const maxStdoutBytes = Math.max(1024, params.maxStdoutBytes);
@@ -115,19 +63,46 @@ async function runLobsterSubprocessOnce(
115
63
  if (nodeOptions.includes("--inspect")) {
116
64
  delete env.NODE_OPTIONS;
117
65
  }
66
+ const spawnTarget =
67
+ process.platform === "win32"
68
+ ? resolveWindowsLobsterSpawn(execPath, argv, env)
69
+ : { command: execPath, argv };
118
70
 
119
71
  return await new Promise<{ stdout: string }>((resolve, reject) => {
120
- const child = spawn(execPath, argv, {
72
+ const child = spawn(spawnTarget.command, spawnTarget.argv, {
121
73
  cwd,
122
74
  stdio: ["ignore", "pipe", "pipe"],
123
75
  env,
124
- shell: useShell,
125
- windowsHide: useShell ? true : undefined,
76
+ windowsHide: spawnTarget.windowsHide,
126
77
  });
127
78
 
128
79
  let stdout = "";
129
80
  let stdoutBytes = 0;
130
81
  let stderr = "";
82
+ let settled = false;
83
+
84
+ const settle = (
85
+ result: { ok: true; value: { stdout: string } } | { ok: false; error: Error },
86
+ ) => {
87
+ if (settled) {
88
+ return;
89
+ }
90
+ settled = true;
91
+ clearTimeout(timer);
92
+ if (result.ok) {
93
+ resolve(result.value);
94
+ } else {
95
+ reject(result.error);
96
+ }
97
+ };
98
+
99
+ const failAndTerminate = (message: string) => {
100
+ try {
101
+ child.kill("SIGKILL");
102
+ } finally {
103
+ settle({ ok: false, error: new Error(message) });
104
+ }
105
+ };
131
106
 
132
107
  child.stdout?.setEncoding("utf8");
133
108
  child.stderr?.setEncoding("utf8");
@@ -136,11 +111,7 @@ async function runLobsterSubprocessOnce(
136
111
  const str = String(chunk);
137
112
  stdoutBytes += Buffer.byteLength(str, "utf8");
138
113
  if (stdoutBytes > maxStdoutBytes) {
139
- try {
140
- child.kill("SIGKILL");
141
- } finally {
142
- reject(new Error("lobster output exceeded maxStdoutBytes"));
143
- }
114
+ failAndTerminate("lobster output exceeded maxStdoutBytes");
144
115
  return;
145
116
  }
146
117
  stdout += str;
@@ -151,46 +122,26 @@ async function runLobsterSubprocessOnce(
151
122
  });
152
123
 
153
124
  const timer = setTimeout(() => {
154
- try {
155
- child.kill("SIGKILL");
156
- } finally {
157
- reject(new Error("lobster subprocess timed out"));
158
- }
125
+ failAndTerminate("lobster subprocess timed out");
159
126
  }, timeoutMs);
160
127
 
161
128
  child.once("error", (err) => {
162
- clearTimeout(timer);
163
- reject(err);
129
+ settle({ ok: false, error: err });
164
130
  });
165
131
 
166
132
  child.once("exit", (code) => {
167
- clearTimeout(timer);
168
133
  if (code !== 0) {
169
- reject(new Error(`lobster failed (${code ?? "?"}): ${stderr.trim() || stdout.trim()}`));
134
+ settle({
135
+ ok: false,
136
+ error: new Error(`lobster failed (${code ?? "?"}): ${stderr.trim() || stdout.trim()}`),
137
+ });
170
138
  return;
171
139
  }
172
- resolve({ stdout });
140
+ settle({ ok: true, value: { stdout } });
173
141
  });
174
142
  });
175
143
  }
176
144
 
177
- async function runLobsterSubprocess(params: {
178
- execPath: string;
179
- argv: string[];
180
- cwd: string;
181
- timeoutMs: number;
182
- maxStdoutBytes: number;
183
- }) {
184
- try {
185
- return await runLobsterSubprocessOnce(params, false);
186
- } catch (err) {
187
- if (process.platform === "win32" && isWindowsSpawnErrorThatCanUseShell(err)) {
188
- return await runLobsterSubprocessOnce(params, true);
189
- }
190
- throw err;
191
- }
192
- }
193
-
194
145
  function parseEnvelope(stdout: string): LobsterEnvelope {
195
146
  const trimmed = stdout.trim();
196
147
 
@@ -229,6 +180,33 @@ function parseEnvelope(stdout: string): LobsterEnvelope {
229
180
  throw new Error("lobster returned invalid JSON envelope");
230
181
  }
231
182
 
183
+ function buildLobsterArgv(action: string, params: Record<string, unknown>): string[] {
184
+ if (action === "run") {
185
+ const pipeline = typeof params.pipeline === "string" ? params.pipeline : "";
186
+ if (!pipeline.trim()) {
187
+ throw new Error("pipeline required");
188
+ }
189
+ const argv = ["run", "--mode", "tool", pipeline];
190
+ const argsJson = typeof params.argsJson === "string" ? params.argsJson : "";
191
+ if (argsJson.trim()) {
192
+ argv.push("--args-json", argsJson);
193
+ }
194
+ return argv;
195
+ }
196
+ if (action === "resume") {
197
+ const token = typeof params.token === "string" ? params.token : "";
198
+ if (!token.trim()) {
199
+ throw new Error("token required");
200
+ }
201
+ const approve = params.approve;
202
+ if (typeof approve !== "boolean") {
203
+ throw new Error("approve required");
204
+ }
205
+ return ["resume", "--token", token, "--approve", approve ? "yes" : "no"];
206
+ }
207
+ throw new Error(`Unknown action: ${action}`);
208
+ }
209
+
232
210
  export function createLobsterTool(api: OpenClawPluginApi) {
233
211
  return {
234
212
  name: "lobster",
@@ -242,11 +220,6 @@ export function createLobsterTool(api: OpenClawPluginApi) {
242
220
  argsJson: Type.Optional(Type.String()),
243
221
  token: Type.Optional(Type.String()),
244
222
  approve: Type.Optional(Type.Boolean()),
245
- // SECURITY: Do not allow the agent to choose an executable path.
246
- // Host can configure the lobster binary via plugin config.
247
- lobsterPath: Type.Optional(
248
- Type.String({ description: "(deprecated) Use plugin config instead." }),
249
- ),
250
223
  cwd: Type.Optional(
251
224
  Type.String({
252
225
  description:
@@ -262,55 +235,19 @@ export function createLobsterTool(api: OpenClawPluginApi) {
262
235
  throw new Error("action required");
263
236
  }
264
237
 
265
- // SECURITY: never allow tool callers (agent/user) to select executables.
266
- // If a host needs to override the binary, it must do so via plugin config.
267
- // We still validate the parameter shape to prevent reintroducing an RCE footgun.
268
- if (typeof params.lobsterPath === "string" && params.lobsterPath.trim()) {
269
- resolveExecutablePath(params.lobsterPath);
270
- }
271
-
272
- const execPath = resolveExecutablePath(
273
- typeof api.pluginConfig?.lobsterPath === "string"
274
- ? api.pluginConfig.lobsterPath
275
- : undefined,
276
- );
238
+ const execPath = "lobster";
277
239
  const cwd = resolveCwd(params.cwd);
278
240
  const timeoutMs = typeof params.timeoutMs === "number" ? params.timeoutMs : 20_000;
279
241
  const maxStdoutBytes =
280
242
  typeof params.maxStdoutBytes === "number" ? params.maxStdoutBytes : 512_000;
281
243
 
282
- const argv = (() => {
283
- if (action === "run") {
284
- const pipeline = typeof params.pipeline === "string" ? params.pipeline : "";
285
- if (!pipeline.trim()) {
286
- throw new Error("pipeline required");
287
- }
288
- const argv = ["run", "--mode", "tool", pipeline];
289
- const argsJson = typeof params.argsJson === "string" ? params.argsJson : "";
290
- if (argsJson.trim()) {
291
- argv.push("--args-json", argsJson);
292
- }
293
- return argv;
294
- }
295
- if (action === "resume") {
296
- const token = typeof params.token === "string" ? params.token : "";
297
- if (!token.trim()) {
298
- throw new Error("token required");
299
- }
300
- const approve = params.approve;
301
- if (typeof approve !== "boolean") {
302
- throw new Error("approve required");
303
- }
304
- return ["resume", "--token", token, "--approve", approve ? "yes" : "no"];
305
- }
306
- throw new Error(`Unknown action: ${action}`);
307
- })();
244
+ const argv = buildLobsterArgv(action, params);
308
245
 
309
246
  if (api.runtime?.version && api.logger?.debug) {
310
247
  api.logger.debug(`lobster plugin runtime=${api.runtime.version}`);
311
248
  }
312
249
 
313
- const { stdout } = await runLobsterSubprocess({
250
+ const { stdout } = await runLobsterSubprocessOnce({
314
251
  execPath,
315
252
  argv,
316
253
  cwd,
@@ -0,0 +1,148 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { resolveWindowsLobsterSpawn } from "./windows-spawn.js";
6
+
7
+ function setProcessPlatform(platform: NodeJS.Platform) {
8
+ Object.defineProperty(process, "platform", {
9
+ value: platform,
10
+ configurable: true,
11
+ });
12
+ }
13
+
14
+ describe("resolveWindowsLobsterSpawn", () => {
15
+ let tempDir = "";
16
+ const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
17
+ const originalPath = process.env.PATH;
18
+ const originalPathAlt = process.env.Path;
19
+ const originalPathExt = process.env.PATHEXT;
20
+ const originalPathExtAlt = process.env.Pathext;
21
+
22
+ beforeEach(async () => {
23
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-win-spawn-"));
24
+ setProcessPlatform("win32");
25
+ });
26
+
27
+ afterEach(async () => {
28
+ if (originalPlatform) {
29
+ Object.defineProperty(process, "platform", originalPlatform);
30
+ }
31
+ if (originalPath === undefined) {
32
+ delete process.env.PATH;
33
+ } else {
34
+ process.env.PATH = originalPath;
35
+ }
36
+ if (originalPathAlt === undefined) {
37
+ delete process.env.Path;
38
+ } else {
39
+ process.env.Path = originalPathAlt;
40
+ }
41
+ if (originalPathExt === undefined) {
42
+ delete process.env.PATHEXT;
43
+ } else {
44
+ process.env.PATHEXT = originalPathExt;
45
+ }
46
+ if (originalPathExtAlt === undefined) {
47
+ delete process.env.Pathext;
48
+ } else {
49
+ process.env.Pathext = originalPathExtAlt;
50
+ }
51
+ if (tempDir) {
52
+ await fs.rm(tempDir, { recursive: true, force: true });
53
+ tempDir = "";
54
+ }
55
+ });
56
+
57
+ it("unwraps cmd shim with %dp0% token", async () => {
58
+ const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
59
+ const shimPath = path.join(tempDir, "shim", "lobster.cmd");
60
+ await fs.mkdir(path.dirname(scriptPath), { recursive: true });
61
+ await fs.mkdir(path.dirname(shimPath), { recursive: true });
62
+ await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
63
+ await fs.writeFile(
64
+ shimPath,
65
+ `@echo off\r\n"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`,
66
+ "utf8",
67
+ );
68
+
69
+ const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env);
70
+ expect(target.command).toBe(process.execPath);
71
+ expect(target.argv).toEqual([scriptPath, "run", "noop"]);
72
+ expect(target.windowsHide).toBe(true);
73
+ });
74
+
75
+ it("unwraps cmd shim with %~dp0% token", async () => {
76
+ const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
77
+ const shimPath = path.join(tempDir, "shim", "lobster.cmd");
78
+ await fs.mkdir(path.dirname(scriptPath), { recursive: true });
79
+ await fs.mkdir(path.dirname(shimPath), { recursive: true });
80
+ await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
81
+ await fs.writeFile(
82
+ shimPath,
83
+ `@echo off\r\n"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`,
84
+ "utf8",
85
+ );
86
+
87
+ const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env);
88
+ expect(target.command).toBe(process.execPath);
89
+ expect(target.argv).toEqual([scriptPath, "run", "noop"]);
90
+ expect(target.windowsHide).toBe(true);
91
+ });
92
+
93
+ it("ignores node.exe shim entries and picks lobster script", async () => {
94
+ const shimDir = path.join(tempDir, "shim-with-node");
95
+ const scriptPath = path.join(tempDir, "shim-dist-node", "lobster-cli.cjs");
96
+ const shimPath = path.join(shimDir, "lobster.cmd");
97
+ await fs.mkdir(path.dirname(scriptPath), { recursive: true });
98
+ await fs.mkdir(shimDir, { recursive: true });
99
+ await fs.writeFile(path.join(shimDir, "node.exe"), "", "utf8");
100
+ await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
101
+ await fs.writeFile(
102
+ shimPath,
103
+ `@echo off\r\n"%~dp0%\\node.exe" "%~dp0%\\..\\shim-dist-node\\lobster-cli.cjs" %*\r\n`,
104
+ "utf8",
105
+ );
106
+
107
+ const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env);
108
+ expect(target.command).toBe(process.execPath);
109
+ expect(target.argv).toEqual([scriptPath, "run", "noop"]);
110
+ expect(target.windowsHide).toBe(true);
111
+ });
112
+
113
+ it("resolves lobster.cmd from PATH and unwraps npm layout shim", async () => {
114
+ const binDir = path.join(tempDir, "node_modules", ".bin");
115
+ const packageDir = path.join(tempDir, "node_modules", "lobster");
116
+ const scriptPath = path.join(packageDir, "dist", "cli.js");
117
+ const shimPath = path.join(binDir, "lobster.cmd");
118
+ await fs.mkdir(path.dirname(scriptPath), { recursive: true });
119
+ await fs.mkdir(binDir, { recursive: true });
120
+ await fs.writeFile(shimPath, "@echo off\r\n", "utf8");
121
+ await fs.writeFile(
122
+ path.join(packageDir, "package.json"),
123
+ JSON.stringify({ name: "lobster", version: "0.0.0", bin: { lobster: "dist/cli.js" } }),
124
+ "utf8",
125
+ );
126
+ await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
127
+
128
+ const env = {
129
+ ...process.env,
130
+ PATH: `${binDir};${process.env.PATH ?? ""}`,
131
+ PATHEXT: ".CMD;.EXE",
132
+ };
133
+ const target = resolveWindowsLobsterSpawn("lobster", ["run", "noop"], env);
134
+ expect(target.command).toBe(process.execPath);
135
+ expect(target.argv).toEqual([scriptPath, "run", "noop"]);
136
+ expect(target.windowsHide).toBe(true);
137
+ });
138
+
139
+ it("fails fast when wrapper cannot be resolved without shell execution", async () => {
140
+ const badShimPath = path.join(tempDir, "bad-shim", "lobster.cmd");
141
+ await fs.mkdir(path.dirname(badShimPath), { recursive: true });
142
+ await fs.writeFile(badShimPath, "@echo off\r\nREM no entrypoint\r\n", "utf8");
143
+
144
+ expect(() => resolveWindowsLobsterSpawn(badShimPath, ["run", "noop"], process.env)).toThrow(
145
+ /without shell execution/,
146
+ );
147
+ });
148
+ });
@@ -0,0 +1,193 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ type SpawnTarget = {
5
+ command: string;
6
+ argv: string[];
7
+ windowsHide?: boolean;
8
+ };
9
+
10
+ function isFilePath(value: string): boolean {
11
+ try {
12
+ const stat = fs.statSync(value);
13
+ return stat.isFile();
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ function resolveWindowsExecutablePath(execPath: string, env: NodeJS.ProcessEnv): string {
20
+ if (execPath.includes("/") || execPath.includes("\\") || path.isAbsolute(execPath)) {
21
+ return execPath;
22
+ }
23
+
24
+ const pathValue = env.PATH ?? env.Path ?? process.env.PATH ?? process.env.Path ?? "";
25
+ const pathEntries = pathValue
26
+ .split(";")
27
+ .map((entry) => entry.trim())
28
+ .filter(Boolean);
29
+
30
+ const hasExtension = path.extname(execPath).length > 0;
31
+ const pathExtRaw =
32
+ env.PATHEXT ??
33
+ env.Pathext ??
34
+ process.env.PATHEXT ??
35
+ process.env.Pathext ??
36
+ ".EXE;.CMD;.BAT;.COM";
37
+ const pathExt = hasExtension
38
+ ? [""]
39
+ : pathExtRaw
40
+ .split(";")
41
+ .map((ext) => ext.trim())
42
+ .filter(Boolean)
43
+ .map((ext) => (ext.startsWith(".") ? ext : `.${ext}`));
44
+
45
+ for (const dir of pathEntries) {
46
+ for (const ext of pathExt) {
47
+ for (const candidateExt of [ext, ext.toLowerCase(), ext.toUpperCase()]) {
48
+ const candidate = path.join(dir, `${execPath}${candidateExt}`);
49
+ if (isFilePath(candidate)) {
50
+ return candidate;
51
+ }
52
+ }
53
+ }
54
+ }
55
+
56
+ return execPath;
57
+ }
58
+
59
+ function resolveBinEntry(binField: string | Record<string, string> | undefined): string | null {
60
+ if (typeof binField === "string") {
61
+ const trimmed = binField.trim();
62
+ return trimmed || null;
63
+ }
64
+ if (!binField || typeof binField !== "object") {
65
+ return null;
66
+ }
67
+
68
+ const preferred = binField.lobster;
69
+ if (typeof preferred === "string" && preferred.trim()) {
70
+ return preferred.trim();
71
+ }
72
+
73
+ for (const value of Object.values(binField)) {
74
+ if (typeof value === "string" && value.trim()) {
75
+ return value.trim();
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ function resolveLobsterScriptFromPackageJson(wrapperPath: string): string | null {
82
+ const wrapperDir = path.dirname(wrapperPath);
83
+ const packageDirs = [
84
+ // Local install: <repo>/node_modules/.bin/lobster.cmd -> ../lobster
85
+ path.resolve(wrapperDir, "..", "lobster"),
86
+ // Global npm install: <npm-prefix>/lobster.cmd -> ./node_modules/lobster
87
+ path.resolve(wrapperDir, "node_modules", "lobster"),
88
+ ];
89
+
90
+ for (const packageDir of packageDirs) {
91
+ const packageJsonPath = path.join(packageDir, "package.json");
92
+ if (!isFilePath(packageJsonPath)) {
93
+ continue;
94
+ }
95
+
96
+ try {
97
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
98
+ bin?: string | Record<string, string>;
99
+ };
100
+ const scriptRel = resolveBinEntry(packageJson.bin);
101
+ if (!scriptRel) {
102
+ continue;
103
+ }
104
+ const scriptPath = path.resolve(packageDir, scriptRel);
105
+ if (isFilePath(scriptPath)) {
106
+ return scriptPath;
107
+ }
108
+ } catch {
109
+ // Ignore malformed package metadata; caller will throw a guided error.
110
+ }
111
+ }
112
+
113
+ return null;
114
+ }
115
+
116
+ function resolveLobsterScriptFromCmdShim(wrapperPath: string): string | null {
117
+ if (!isFilePath(wrapperPath)) {
118
+ return null;
119
+ }
120
+
121
+ try {
122
+ const content = fs.readFileSync(wrapperPath, "utf8");
123
+ const candidates: string[] = [];
124
+ const extractRelativeFromToken = (token: string): string | null => {
125
+ const match = token.match(/%~?dp0%\s*[\\/]*(.*)$/i);
126
+ if (!match) {
127
+ return null;
128
+ }
129
+ const relative = match[1];
130
+ if (!relative) {
131
+ return null;
132
+ }
133
+ return relative;
134
+ };
135
+
136
+ const matches = content.matchAll(/"([^"\r\n]*)"/g);
137
+ for (const match of matches) {
138
+ const token = match[1] ?? "";
139
+ const relative = extractRelativeFromToken(token);
140
+ if (!relative) {
141
+ continue;
142
+ }
143
+
144
+ const normalizedRelative = relative
145
+ .trim()
146
+ .replace(/[\\/]+/g, path.sep)
147
+ .replace(/^[\\/]+/, "");
148
+ const candidate = path.resolve(path.dirname(wrapperPath), normalizedRelative);
149
+ if (isFilePath(candidate)) {
150
+ candidates.push(candidate);
151
+ }
152
+ }
153
+
154
+ const nonNode = candidates.find((candidate) => {
155
+ const base = path.basename(candidate).toLowerCase();
156
+ return base !== "node.exe" && base !== "node";
157
+ });
158
+ if (nonNode) {
159
+ return nonNode;
160
+ }
161
+ } catch {
162
+ // Ignore unreadable shims; caller will throw a guided error.
163
+ }
164
+
165
+ return null;
166
+ }
167
+
168
+ export function resolveWindowsLobsterSpawn(
169
+ execPath: string,
170
+ argv: string[],
171
+ env: NodeJS.ProcessEnv,
172
+ ): SpawnTarget {
173
+ const resolvedExecPath = resolveWindowsExecutablePath(execPath, env);
174
+ const ext = path.extname(resolvedExecPath).toLowerCase();
175
+ if (ext !== ".cmd" && ext !== ".bat") {
176
+ return { command: resolvedExecPath, argv };
177
+ }
178
+
179
+ const scriptPath =
180
+ resolveLobsterScriptFromCmdShim(resolvedExecPath) ??
181
+ resolveLobsterScriptFromPackageJson(resolvedExecPath);
182
+ if (!scriptPath) {
183
+ throw new Error(
184
+ `${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).`,
185
+ );
186
+ }
187
+
188
+ const entryExt = path.extname(scriptPath).toLowerCase();
189
+ if (entryExt === ".exe") {
190
+ return { command: scriptPath, argv, windowsHide: true };
191
+ }
192
+ return { command: process.execPath, argv: [scriptPath, ...argv], windowsHide: true };
193
+ }