@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 +1 -1
- package/package.json +1 -1
- package/src/lobster-tool.test.ts +137 -33
- package/src/lobster-tool.ts +76 -139
- package/src/windows-spawn.test.ts +148 -0
- package/src/windows-spawn.ts +193 -0
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
|
-
-
|
|
75
|
+
- Ensure `lobster` is available on `PATH` for the gateway process.
|
package/package.json
CHANGED
package/src/lobster-tool.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
|
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("
|
|
215
|
+
tool.execute("call-pipeline-missing", {
|
|
154
216
|
action: "run",
|
|
155
|
-
pipeline: "noop",
|
|
156
|
-
lobsterPath: "./lobster",
|
|
157
217
|
}),
|
|
158
|
-
).rejects.toThrow(/
|
|
218
|
+
).rejects.toThrow(/pipeline required/);
|
|
159
219
|
});
|
|
160
220
|
|
|
161
|
-
it("
|
|
221
|
+
it("requires token and approve for resume action", async () => {
|
|
162
222
|
const tool = createLobsterTool(fakeApi());
|
|
163
223
|
await expect(
|
|
164
|
-
tool.execute("
|
|
165
|
-
action: "
|
|
166
|
-
|
|
167
|
-
|
|
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(/
|
|
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("
|
|
195
|
-
spawnState.queue.push({
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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(
|
|
205
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
expect(
|
|
214
|
-
expect(
|
|
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("
|
|
218
|
-
|
|
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("
|
|
325
|
+
tool.execute("call-win-no-retry", {
|
|
223
326
|
action: "run",
|
|
224
327
|
pipeline: "noop",
|
|
225
328
|
}),
|
|
226
|
-
).rejects.toThrow(/
|
|
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 () => {
|
package/src/lobster-tool.ts
CHANGED
|
@@ -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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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(
|
|
72
|
+
const child = spawn(spawnTarget.command, spawnTarget.argv, {
|
|
121
73
|
cwd,
|
|
122
74
|
stdio: ["ignore", "pipe", "pipe"],
|
|
123
75
|
env,
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
+
settle({
|
|
135
|
+
ok: false,
|
|
136
|
+
error: new Error(`lobster failed (${code ?? "?"}): ${stderr.trim() || stdout.trim()}`),
|
|
137
|
+
});
|
|
170
138
|
return;
|
|
171
139
|
}
|
|
172
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|